Skip to main content

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,
},
},
],
OptionTypeDefaultDescription
sourcePlaybackSourceRequired. The recording. See below
channel_filterstringnoneReplay only messages whose channel matches this pattern
default_channelstring"playback"Channel given to messages that carry none
speednumber1Relative to the recorded pace. 10 replays ten times faster
loopbooleantrueStart over when the recording ends

Where the recording lives

A recording is a dump of an event stream, not necessarily a file. source accepts:

FormNotes
{ 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 / ArrayBufferThe dump as bytes
Array<unknown>Records you already parsed, skipping JSON.parse
ReadableStream / AsyncIterableDrained to completion before the replay starts
() => sourceProduced 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.