7 KiB
@flybrain/feed
Shared TypeScript types, a binary snapshot codec, a pinned JSON Schema and a runnable fake
flysim for local development — the contract between flysim (the Rust simulation service) and
its consumers, flystage (the display page) and flybridge (the Twitch chat/points bridge).
Binding contracts: docs/feed-protocol.md (the WebSocket
snapshot feed) and docs/control-api.md (the localhost HTTP control
API). This package is the TypeScript side of those contracts; services/flysim mirrors the same
shapes with serde, and both sides test against src/schema.json.
What is in here
src/types.ts: every interface from the two contract docs (FeedHeader,FeedEvent,ClientHello,StatusResponse,StimulateRequest/StimulateResponse,RewardRequest,EventsResponse, and friends), plus the shared constants (FEED_PROTOCOL,FEED_PORT,CONTROL_PORT,FRAME_WIDTH,FRAME_HEIGHT,AUDIO_RATE).src/codec.ts:encodeSnapshot/decodeSnapshotimplement the exact binary framing fromdocs/feed-protocol.md(u32 LE headerLength | header JSON | attachments...). Pure functions onUint8Array/DataView/TextEncoder/TextDecoderonly, so the browser stage can use the same code as Node tests.src/schema.json: a strict JSON Schema (draft 2020-12) forFeedHeader, hand-written to matchtypes.ts.additionalProperties: falseon every fixed-shape object and enums on every closed set of string values (status,game.mode,milestonefields, eventkind, rewardkind, attachmentkind). The one deliberate exception isrates, which is genuinely an open string-keyed map per the contract doc (Record<string, number>) — see the comment in the schema for why it is not locked down the same way.src/chat.ts:sanitizeChatText— the chat text rules fromdocs/control-api.md'sPOST /chat, in the same order the Rust side applies them (NFC, no control characters, a letters/digits/space/punctuation allowlist that excludes emoji and combining marks, whitespace collapsing, a 200-code-point cap, and a URL refusal).classifyChatTextreturns the rejection reason, which is the label on flysim'sfly_chat_rejected_total{reason}.tests/fixtures/chat-cases.jsonis the hostile-input corpus both this package's tests andservices/flysim/crates/flysim/tests/chat.rsload, so the two implementations cannot drift.src/names.ts:validateDisplayName, the display-name chokepoint (see the bridge README).src/fixture.ts: the.flyfeedfixture container — a recorded run of the feed ("FLYFEED\0", a version, a JSON manifest, then the exact wire messages length-prefixed).encodeFlyfeed/decodeFlyfeedfor whole files,encodeFlyfeedHeader/encodeFlyfeedRecordfor a streaming recorder, anditerateFlyfeedRecordsyielding zero-copy views. PureUint8Arrayfunctions, soapps/stage/tools/record-fixture.mts(Node) writes the files the stage's player mode (browser) reads. A.flyfeed.gzis this format gzipped; the caller inflates andisGzipsniffs which it has. The manifest'sattachmentPolicyrecords what the recorder kept per attachment kind, because a full-rate 120 s recording is hundreds of MB.src/fake/: a self-contained syntheticflysim(simulator.ts, the state machine;prng.ts, a small seeded PRNG;server.ts, the WebSocket + HTTP wiring and CLI). Deterministic given a seed, and has no dependency on@flybrain/brainor the real dataset.
Running the fake server
npx tsx packages/feed/src/fake/server.ts \
[--feed-port 7400] [--control-port 7401] \
[--scenario boot|running|stuck|milestone] \
[--allow-reward] [--seed 12345] [--no-chat] [--no-chatter] \
[--spikes-per-tick 30000]
boot: stays inbootingstatus forever — useful for testing the boot screen.running(default): skips the boot wait so the feed is immediatelyrunning.stuck: milestone rank fixed,sinceSecondsclimbs without bound,attemptsincrements periodically — exercises the stuck-o-meter.milestone: the milestone rank steps up the ladder every 90 s. The ladder is 38 rungs, the same length as the real Pokémon Red one (docs/design/ladder.md), somilestone.totalis 38 andrankruns 0..37. The wording differs from the real ladder's on purpose: the stage draws the current rung's label from the feed and the others from its own per-game config, and the two differing is what proves which one is authoritative.
All four scenarios wander command_0..7 and the other tracked rates, hold a dominant D-pad
direction for ~400 ms at a time with occasional A/B pulses, emit reward events roughly every 20 s,
auto-checkpoint every 5 s, and stream a procedurally drawn 160x144 Game Boy-palette test frame (no
Nintendo assets), a quiet 220 Hz sine at 48 kHz stereo f32, and a spikes bitset with ~30,000 bits
set per snapshot by default — matching the live service's measured rate, not a placeholder — at
30 Hz while running and 2 Hz otherwise. Override with --spikes-per-tick (or spikesPerTick on
startFakeServer/FakeFlysim) to test against a different density.
It also implements the control API in full: GET /status, POST /stimulate (6/min global limit,
no overlap with an active pulse, emits a sugar FeedEvent), POST /reward (403 unless
--allow-reward), POST /chat (name validation, sanitizeChatText, 1 per 2 s per name and 5 per
s globally, 403 with --no-chat), POST /checkpoint, POST /pause/POST /resume,
GET /events?since=&limit=, GET /healthz.
The fake also scripts plausible viewer chatter into the header's chat ring every 8 to 22
simulated seconds, including the bridge's own bot: true template replies, so the stage's
persistent CHAT panel has something to render before either real service exists. --no-chatter
leaves the ring to whatever posts to /chat; --no-chat is the kill switch, and makes the header
omit chat entirely.
startFakeServer(options) (exported from the ./fake subpath) starts the same thing
in-process on ports you choose — pass feedPort: 0 / controlPort: 0 for ephemeral ports — which
is how the integration tests drive it without spawning a child process.
How flystage and flybridge consume this
flystageimports@flybrain/feedfor the types anddecodeSnapshot, opens the feed WebSocket, sends{"protocol":1,"client":"stage","wants":["frame","audio","spikes"]}, and renders each decodedFeedHeaderplus attachments. Against@flybrain/feed/fake's fake server during development, beforeflysimexists.flybridgeimports the control API request/response types, callsPOST /stimulateon chat commands and Channel Points redemptions, and readsGET /status/GET /eventsfor its own state. It asks for no feed attachments (wants: []) since it never renders anything.
Scripts
npm test # node --import tsx --test tests/**/*.test.ts
npm run typecheck