Playback
Replays a recorded event stream, paced from its own timestamps. Use it to bring a mapping up before it ever meets a broker, to demo a scene with no infrastructure behind it, and to reproduce a problem frame by frame.
When to use it
- Building a mapping. Record a few seconds of your real stream, iterate against the dump, then change one line to go live. The recorded channel is preserved, so a mapping behaves identically replayed and live — which is the whole point of replaying.
- Demos. A scene that moves convincingly with nothing running behind it, in Node.js or in the browser.
- Reproducing a bug. The same events, in the same order, every time.
No install: playback has no dependencies of its own.
Configuration
sources: [
{
kind: "playback",
config: {
source: { file_path: "./recordings/plant.json" },
speed: 2,
loop: true,
},
},
],
| Option | Type | Default | Description |
|---|---|---|---|
source | PlaybackSource | — | Required. The recording. See below |
channel_filter | string | none | Replay only messages whose channel matches this pattern |
default_channel | string | "playback" | Channel given to messages that carry none |
speed | number | 1 | Relative to the recorded pace. 10 replays ten times faster |
loop | boolean | true | Start over when the recording ends |
Where the recording lives
A recording is a dump of an event stream, not necessarily a file. source accepts:
| Form | Notes |
|---|---|
{ file_path: "./dump.json" } | Node.js only. Relative to the working directory |
{ url: "/recordings/dump.json" } | Anything fetch can retrieve — browser and Node.js |
"...the dump itself..." | A string is the content, never a path or a URL |
Uint8Array / ArrayBuffer | The dump as bytes |
Array<unknown> | Records you already parsed, skipping JSON.parse |
ReadableStream / AsyncIterable | Drained to completion before the replay starts |
() => source | Produced on start(), for a source built lazily |
Only { file_path } needs Node.js — which is what lets the same transport drive a server-side replay and a browser
demo from a bundled dump.
Recording formats
Three shapes are understood:
Envelopes — the richest, and what to record if you have the choice. Channels and timestamps are both preserved:
[
{ "channel": "devices/dev-01/telemetry", "timestamp": "2026-01-01T00:00:00.000Z", "payload": { "pos": [0, 1, 0] } },
{ "channel": "devices/dev-02/telemetry", "timestamp": "2026-01-01T00:00:00.016Z", "payload": { "pos": [2, 1, 0] } }
]
MQTT trace dumps — lines of <topic> <json>, which is what mosquitto_sub -v writes. Replayed on their original
topics:
devices/dev-01/telemetry {"pos":[0,1,0]}
devices/dev-02/telemetry {"pos":[2,1,0]}
Bare payloads — a JSON array with no channel information. Every event is replayed on default_channel, so mappings
cannot route on the channel:
[{ "pos": [0, 1, 0] }, { "pos": [0, 1.5, 0] }]
Recording your own stream
For an MQTT source, the broker's own tooling is enough:
mosquitto_sub -h broker.example.com -t 'plant/#' -v > plant.dump
That produces a trace dump, replayable as-is. For any other source, wrap the sink — an EventSink is one method, so
tapping the stream takes no SDK support:
const recording: Array<unknown> = [];
sources: [
sink =>
new MyTransport(config, {
ingest(event) {
recording.push({
channel: event.channel,
timestamp: (event.source_timestamp ?? event.received_at)?.toISOString(),
payload: event.payload,
});
return sink.ingest(event);
},
}),
],
Narrowing a dump
channel_filter selects a subset at replay time, so a full trace can be reused for several mappings without being
pre-split:
config: {
source: { file_path: "./plant.dump" },
channel_filter: "plant/+/+/motor",
}
It is a replayer, not a reader
Playback buffers the whole recording before replaying it. Looping needs every message in memory, and pacing needs
the first timestamp before the second message can be scheduled — so a streamed source is drained to completion by
start().
For a genuinely live source, write a transport that pushes as it reads.