/** * `.flyfeed` fixture container: a recorded run of the feed protocol, replayable with no service. * * A fixture is the exact wire bytes of `docs/feed-protocol.md` snapshots, length-prefixed, behind * a small JSON manifest that records what was captured and what (if anything) the recorder * dropped. Shared here rather than in `apps/stage` because the recorder (Node), the player * (browser) and the tests all need one implementation. * * ``` * "FLYFEED\0" 8 bytes magic * u32 LE version 1 * u32 LE manifestLen * manifest JSON UTF-8 * records repeated: u32 LE byteLength | wire message bytes * ``` * * No per-record timestamp: every snapshot header already carries `wallMs`, so a player derives * its schedule from the messages themselves and a fixture of a real run needs no extra metadata. * * Pure functions on `Uint8Array` only — no `node:fs`, no `node:zlib`. A `.flyfeed.gz` is just a * gzipped file of this format; the caller inflates it (`DecompressionStream` in the browser, * `node:zlib` in tools) and passes the inflated bytes here. {@link isGzip} sniffs which it has. */ import type { AttachmentKind } from './types'; /** Magic bytes at the start of every `.flyfeed` file. */ export const FLYFEED_MAGIC = 'FLYFEED\0'; /** Container format version. Bumps on any breaking change to the framing or manifest shape. */ export const FLYFEED_VERSION = 1; const U32_BYTES = 4; const MAGIC_BYTES = 8; /** * What the recorder kept for one attachment kind. * * `stride: 1` keeps every snapshot's attachment, `3` keeps every third, and `0` keeps none. * `seconds` (when set) additionally limits the kind to the first N seconds of the recording. * Attachments the recorder dropped are removed from that snapshot's `header.attachments`, which * is exactly what a consumer that had not asked for them would have received. */ export interface FlyfeedAttachmentPolicy { stride: number; seconds?: number; } /** The manifest at the head of a `.flyfeed` file. */ export interface FlyfeedManifest { /** Fixture name, e.g. `steady`. Matches the file basename by convention. */ name: string; /** Feed protocol version of the recorded messages. */ protocol: 1; /** ISO 8601 timestamp of the recording. */ recordedAt: string; /** Human description of where the messages came from, e.g. `fake-flysim scenario=running seed=7`. */ source: string; /** Number of records in the file. */ snapshotCount: number; /** Wall-clock span of the recording in ms (last `wallMs` minus first). */ durationMs: number; /** Nominal snapshot rate of the source feed, Hz. */ hz: number; /** Per-kind record of what the recorder kept. Absent kinds were never offered by the source. */ attachmentPolicy: Partial>; /** Free-form notes, rendered by tooling rather than parsed. */ notes?: string[]; } /** Thrown on any framing or manifest violation. */ export class FlyfeedError extends Error { constructor(message: string) { super(message); this.name = 'FlyfeedError'; } } /** * Encode the file head (magic, version, manifest). A streaming recorder writes this once, then * appends {@link encodeFlyfeedRecord} per snapshot, so it never holds the whole run in memory. */ export function encodeFlyfeedHeader(manifest: FlyfeedManifest): Uint8Array { if (manifest.protocol !== 1) { throw new FlyfeedError(`unsupported protocol ${String(manifest.protocol)}, expected 1`); } const manifestJson = new TextEncoder().encode(JSON.stringify(manifest)); const out = new Uint8Array(MAGIC_BYTES + U32_BYTES * 2 + manifestJson.byteLength); const view = new DataView(out.buffer); for (let i = 0; i < MAGIC_BYTES; i++) out[i] = FLYFEED_MAGIC.charCodeAt(i); view.setUint32(MAGIC_BYTES, FLYFEED_VERSION, true); view.setUint32(MAGIC_BYTES + U32_BYTES, manifestJson.byteLength, true); out.set(manifestJson, MAGIC_BYTES + U32_BYTES * 2); return out; } /** Encode one length-prefixed record around an already-encoded wire message. */ export function encodeFlyfeedRecord(message: Uint8Array): Uint8Array { const out = new Uint8Array(U32_BYTES + message.byteLength); new DataView(out.buffer).setUint32(0, message.byteLength, true); out.set(message, U32_BYTES); return out; } /** Encode a complete `.flyfeed` file. Convenience over the two streaming primitives. */ export function encodeFlyfeed(manifest: FlyfeedManifest, messages: readonly Uint8Array[]): Uint8Array { const head = encodeFlyfeedHeader({ ...manifest, snapshotCount: messages.length }); let total = head.byteLength; for (const message of messages) total += U32_BYTES + message.byteLength; const out = new Uint8Array(total); out.set(head, 0); const view = new DataView(out.buffer); let offset = head.byteLength; for (const message of messages) { view.setUint32(offset, message.byteLength, true); offset += U32_BYTES; out.set(message, offset); offset += message.byteLength; } return out; } /** Read the manifest and return where the records begin. */ export function readFlyfeedManifest(bytes: Uint8Array): { manifest: FlyfeedManifest; bodyOffset: number } { if (bytes.byteLength < MAGIC_BYTES + U32_BYTES * 2) { throw new FlyfeedError(`truncated file: ${bytes.byteLength} bytes is shorter than the header`); } for (let i = 0; i < MAGIC_BYTES; i++) { if (bytes[i] !== FLYFEED_MAGIC.charCodeAt(i)) { throw new FlyfeedError( isGzip(bytes) ? 'file is gzipped: inflate it before decoding (see docs in src/fixture.ts)' : 'not a .flyfeed file: bad magic', ); } } const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); const version = view.getUint32(MAGIC_BYTES, true); if (version !== FLYFEED_VERSION) { throw new FlyfeedError(`unsupported .flyfeed version ${version}, expected ${FLYFEED_VERSION}`); } const manifestLength = view.getUint32(MAGIC_BYTES + U32_BYTES, true); const manifestStart = MAGIC_BYTES + U32_BYTES * 2; if (manifestStart + manifestLength > bytes.byteLength) { throw new FlyfeedError( `truncated file: manifest claims ${manifestLength} bytes but only ${bytes.byteLength - manifestStart} remain`, ); } let manifest: FlyfeedManifest; try { manifest = JSON.parse(new TextDecoder().decode(bytes.subarray(manifestStart, manifestStart + manifestLength))) as FlyfeedManifest; } catch (cause) { throw new FlyfeedError(`manifest is not valid JSON: ${(cause as Error).message}`); } if (manifest.protocol !== 1) { throw new FlyfeedError(`unsupported protocol ${String(manifest.protocol)}, expected 1`); } return { manifest, bodyOffset: manifestStart + manifestLength }; } /** * Walk the records from `bodyOffset`, yielding zero-copy views into `bytes`. * * Views, not copies: a 120 s fixture is hundreds of megabytes of attachments and the player * only ever reads the most recent one. */ export function* iterateFlyfeedRecords(bytes: Uint8Array, bodyOffset: number): Generator { const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); let offset = bodyOffset; while (offset < bytes.byteLength) { if (offset + U32_BYTES > bytes.byteLength) { throw new FlyfeedError('truncated file: not enough bytes to read the next record length'); } const length = view.getUint32(offset, true); offset += U32_BYTES; if (offset + length > bytes.byteLength) { throw new FlyfeedError( `truncated record: claims ${length} bytes but only ${bytes.byteLength - offset} remain`, ); } yield bytes.subarray(offset, offset + length); offset += length; } } /** Decode a complete `.flyfeed` file into its manifest and its records (zero-copy views). */ export function decodeFlyfeed(bytes: Uint8Array): { manifest: FlyfeedManifest; messages: Uint8Array[] } { const { manifest, bodyOffset } = readFlyfeedManifest(bytes); const messages = [...iterateFlyfeedRecords(bytes, bodyOffset)]; if (manifest.snapshotCount !== messages.length) { throw new FlyfeedError( `manifest claims ${manifest.snapshotCount} snapshots but the file holds ${messages.length}`, ); } return { manifest, messages }; } /** True when `bytes` starts with the gzip magic (`1f 8b`), i.e. it is a `.flyfeed.gz`. */ export function isGzip(bytes: Uint8Array): boolean { return bytes.byteLength >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b; }