@volter/world-core 3.0.151 → 3.0.153

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/src/derived-core.d.ts +8 -0
  2. package/dist/src/derived-core.js +2 -0
  3. package/dist/src/derived.d.ts +4 -0
  4. package/dist/src/events.d.ts +4 -4
  5. package/dist/src/events.js +39 -15
  6. package/dist/src/index.d.ts +3 -1
  7. package/dist/src/index.js +2 -1
  8. package/dist/src/live/access.d.ts +22 -0
  9. package/dist/src/live/access.js +83 -0
  10. package/dist/src/live/amf0.d.ts +7 -0
  11. package/dist/src/live/amf0.js +122 -0
  12. package/dist/src/live/flv.d.ts +42 -0
  13. package/dist/src/live/flv.js +148 -0
  14. package/dist/src/live/fmp4.d.ts +25 -0
  15. package/dist/src/live/fmp4.js +96 -0
  16. package/dist/src/live/hls.d.ts +22 -0
  17. package/dist/src/live/hls.js +46 -0
  18. package/dist/src/live/index.d.ts +9 -0
  19. package/dist/src/live/index.js +8 -0
  20. package/dist/src/live/ingest.d.ts +25 -0
  21. package/dist/src/live/ingest.js +229 -0
  22. package/dist/src/live/rtmp.d.ts +35 -0
  23. package/dist/src/live/rtmp.js +456 -0
  24. package/dist/src/live/segmenter.d.ts +57 -0
  25. package/dist/src/live/segmenter.js +204 -0
  26. package/dist/src/live/types.d.ts +102 -0
  27. package/dist/src/live/types.js +5 -0
  28. package/dist/src/pack-fetch.d.ts +4 -0
  29. package/dist/src/pack-fetch.js +24 -2
  30. package/dist/src/packRegistry.d.ts +1 -0
  31. package/dist/src/serve-http.d.ts +7 -1
  32. package/dist/src/serve-http.js +36 -3
  33. package/dist/src/twin-fetch.d.ts +4 -1
  34. package/package.json +5 -1
  35. package/src/derived-core.ts +10 -0
  36. package/src/derived.ts +4 -0
  37. package/src/events.ts +40 -16
  38. package/src/index.ts +4 -1
  39. package/src/live/access.ts +89 -0
  40. package/src/live/amf0.ts +80 -0
  41. package/src/live/flv.ts +145 -0
  42. package/src/live/fmp4.ts +102 -0
  43. package/src/live/hls.ts +59 -0
  44. package/src/live/index.ts +13 -0
  45. package/src/live/ingest.ts +210 -0
  46. package/src/live/rtmp.ts +375 -0
  47. package/src/live/segmenter.ts +218 -0
  48. package/src/live/types.ts +105 -0
  49. package/src/pack-fetch.ts +28 -2
  50. package/src/packRegistry.ts +1 -1
  51. package/src/serve-http.ts +29 -4
  52. package/src/twin-fetch.ts +4 -2
@@ -0,0 +1,218 @@
1
+ // A publish's media cut into HLS segments (RFC 8216 §3: each segment begins with a keyframe, so a player can start at
2
+ // any one): a segment closes at the first video keyframe at or after the target duration from its start; audio follows
3
+ // the video's cuts. A stream with no video is cut on its audio's time. Every timestamp is the publisher's (milliseconds)
4
+ // plus the recording's offset, so a recording a publisher continues keeps its timeline. A change of encoder
5
+ // configuration closes the segment under way and opens a new init. What a segment under way may hold is bounded: a
6
+ // stream that sends no keyframe within the ceiling (no periodic IDR: intra-refresh, an endless GOP), or whose segment
7
+ // passes its byte or frame ceiling (stalled timestamps), is refused (`LiveOverrun`), never held without end. Pure: bytes
8
+ // in, segments out.
9
+ import type { LiveAudio, LiveVideo } from './types.ts';
10
+ import { type AacConfig, type AvcConfig } from './flv.ts';
11
+ import { AUDIO_TRACK, type Fragment, initSegment, mediaSegment, VIDEO_TIMESCALE, VIDEO_TRACK } from './fmp4.ts';
12
+
13
+ type VideoSample = { dts: number; cts: number; key: boolean; data: Uint8Array };
14
+ type AudioSample = { dts: number; data: Uint8Array };
15
+
16
+ /** A segment closed: its bytes, its media start and duration (seconds), and, when it opens a new init, that init. */
17
+ export type Cut = {
18
+ /** the segment's number in its recording */
19
+ sequence: number;
20
+ bytes: Uint8Array;
21
+ start: number;
22
+ duration: number;
23
+ init?: { bytes: Uint8Array; video?: LiveVideo; audio?: LiveAudio };
24
+ };
25
+
26
+ const AAC_FRAME = 1024;
27
+ /** The longest a stream may go without a keyframe: ten target durations, at least 20 s, past OBS's automatic interval
28
+ * of 250 frames (8.3 s at 30 fps, 10.4 s at 24). */
29
+ const MIN_KEYFRAME_CEILING_MS = 20_000;
30
+ /** What one segment under way may hold: at a Twitch-like 8 Mbit/s, 20 s is 20 MB. */
31
+ const MAX_SEGMENT_BYTES = 64 * 1024 * 1024;
32
+ const MAX_SEGMENT_FRAMES = 10_000;
33
+
34
+ /** A stream the segmenter will not hold: its publish is ended with the reason (ingest.ts). */
35
+ export class LiveOverrun extends Error {}
36
+
37
+ export class Segmenter {
38
+ private avc?: AvcConfig;
39
+ private aac?: AacConfig;
40
+ private meta: Record<string, unknown> = {};
41
+ private freshInit = true;
42
+ private video: VideoSample[] = [];
43
+ private audio: AudioSample[] = [];
44
+ private start?: number;
45
+ private keyed = false;
46
+ private audioNext?: number;
47
+ private lastVideoDuration = 33;
48
+ private sequence = 0;
49
+ /** what the segment under way holds, and since when video has come with no keyframe to start on */
50
+ private bytes = 0;
51
+ private frames = 0;
52
+ private unkeyedSince?: number;
53
+
54
+ constructor(private readonly options: { targetMs: number; offsetMs: number; firstSequence: number }) {
55
+ this.sequence = options.firstSequence;
56
+ }
57
+
58
+ /** The publisher's metadata (`onMetaData`): the size and rate a stream's SPS does not say, and whether video comes. */
59
+ metadata(meta: Record<string, unknown>): void { this.meta = { ...this.meta, ...meta }; }
60
+
61
+ videoConfig(config: AvcConfig): Cut[] {
62
+ if (this.avc && same(this.avc.record, config.record)) return [];
63
+ const out = this.flush();
64
+ this.avc = config;
65
+ this.freshInit = true;
66
+ this.keyed = false;
67
+ return out;
68
+ }
69
+
70
+ audioConfig(config: AacConfig): Cut[] {
71
+ if (this.aac && same(this.aac.asc, config.asc)) return [];
72
+ const out = this.aac ? this.flush() : [];
73
+ this.aac = config;
74
+ this.freshInit = true;
75
+ return out;
76
+ }
77
+
78
+ /** A video frame at its decode time: the segment it closes, if it closes one. */
79
+ videoFrame(timestamp: number, key: boolean, cts: number, data: Uint8Array): Cut[] {
80
+ if (!this.avc) return [];
81
+ const dts = timestamp + this.options.offsetMs;
82
+ const out: Cut[] = [];
83
+ if (!this.keyed) {
84
+ // a stream is played from a keyframe: what comes before the first is undecodable, and audio before it unheard
85
+ if (!key) {
86
+ this.unkeyedSince ??= dts;
87
+ if (dts - this.unkeyedSince >= this.keyframeCeiling()) throw this.noKeyframe();
88
+ return [];
89
+ }
90
+ this.keyed = true;
91
+ this.unkeyedSince = undefined;
92
+ if (this.video.length || this.audioOnlyStarted()) out.push(...this.flush());
93
+ this.start = dts;
94
+ this.audio = this.audio.filter((a) => a.dts >= dts);
95
+ } else if (key && this.start !== undefined && dts - this.start >= this.options.targetMs) {
96
+ const cut = this.cut(dts);
97
+ if (cut) out.push(cut);
98
+ this.start = dts;
99
+ }
100
+ this.within(dts, data.length, key);
101
+ this.video.push({ dts, cts, key, data });
102
+ this.bytes += data.length;
103
+ this.frames += 1;
104
+ return out;
105
+ }
106
+
107
+ audioFrame(timestamp: number, data: Uint8Array): Cut[] {
108
+ if (!this.aac || data.length === 0) return [];
109
+ const dts = timestamp + this.options.offsetMs;
110
+ if (this.expectsVideo()) {
111
+ if (!this.keyed) return [];
112
+ this.within(dts, data.length);
113
+ this.audio.push({ dts, data });
114
+ this.bytes += data.length;
115
+ this.frames += 1;
116
+ return [];
117
+ }
118
+ // audio alone: cut on its own time
119
+ const out: Cut[] = [];
120
+ if (this.start === undefined) this.start = dts;
121
+ else if (dts - this.start >= this.options.targetMs) {
122
+ const cut = this.cut(dts);
123
+ if (cut) out.push(cut);
124
+ this.start = dts;
125
+ }
126
+ this.within(dts, data.length);
127
+ this.audio.push({ dts, data });
128
+ this.bytes += data.length;
129
+ this.frames += 1;
130
+ return out;
131
+ }
132
+
133
+ /** The segment under way, closed (the publish's end, or a configuration change). */
134
+ flush(): Cut[] {
135
+ const cut = this.cut(undefined);
136
+ this.video = [];
137
+ this.audio = [];
138
+ this.start = undefined;
139
+ this.bytes = 0;
140
+ this.frames = 0;
141
+ return cut ? [cut] : [];
142
+ }
143
+
144
+ private keyframeCeiling(): number { return Math.max(MIN_KEYFRAME_CEILING_MS, 10 * this.options.targetMs); }
145
+ private noKeyframe(): LiveOverrun {
146
+ return new LiveOverrun(`The stream sent no keyframe for ${Math.round(this.keyframeCeiling() / 1000)} s; send one at least every ${Math.round(this.options.targetMs / 1000)} s (the encoder's keyframe interval).`);
147
+ }
148
+ /** A frame the segment under way may still take, or the refusal past its ceilings. */
149
+ private within(dts: number, size: number, key = false): void {
150
+ if (!key && this.start !== undefined && dts - this.start >= this.keyframeCeiling()) throw this.noKeyframe();
151
+ if (this.bytes + size > MAX_SEGMENT_BYTES || this.frames + 1 > MAX_SEGMENT_FRAMES) throw new LiveOverrun(`A segment passed ${MAX_SEGMENT_BYTES} bytes or ${MAX_SEGMENT_FRAMES} frames before a keyframe closed it.`);
152
+ }
153
+
154
+ private expectsVideo(): boolean { return this.avc !== undefined || this.meta.videocodecid !== undefined; }
155
+ private audioOnlyStarted(): boolean { return !this.expectsVideo() && this.audio.length > 0; }
156
+
157
+ private cut(at: number | undefined): Cut | undefined {
158
+ const video = this.video;
159
+ const audio = at === undefined ? this.audio : this.audio.filter((a) => a.dts < at);
160
+ this.audio = at === undefined ? [] : this.audio.filter((a) => a.dts >= at);
161
+ this.video = [];
162
+ // what the next segment already holds: the audio past the cut
163
+ this.bytes = this.audio.reduce((n, a) => n + a.data.length, 0);
164
+ this.frames = this.audio.length;
165
+ if (video.length === 0 && audio.length === 0) return undefined;
166
+ const fragments: Fragment[] = [];
167
+ let startMs: number;
168
+ let durationMs: number;
169
+ if (video.length) {
170
+ const samples = video.map((s, i) => {
171
+ const next = i + 1 < video.length ? video[i + 1]!.dts : at;
172
+ const ms = next === undefined ? this.lastVideoDuration : Math.max(1, next - s.dts);
173
+ if (next !== undefined) this.lastVideoDuration = ms;
174
+ return { duration: Math.round(ms * 90), data: s.data, key: s.key, cts: Math.round(s.cts * 90) };
175
+ });
176
+ startMs = video[0]!.dts;
177
+ durationMs = samples.reduce((n, s) => n + s.duration, 0) / 90;
178
+ fragments.push({ track: VIDEO_TRACK, baseTime: Math.round(startMs * (VIDEO_TIMESCALE / 1000)), samples });
179
+ } else {
180
+ startMs = audio[0]!.dts;
181
+ durationMs = 0;
182
+ }
183
+ if (audio.length && this.aac) {
184
+ const rate = this.aac.sampleRate;
185
+ // a running sample clock, resynchronised to the publisher's timestamps when they drift past 100 ms (a gap)
186
+ const expected = Math.round(audio[0]!.dts * rate / 1000);
187
+ if (this.audioNext === undefined || Math.abs(this.audioNext - expected) > rate / 10) this.audioNext = expected;
188
+ const baseTime = this.audioNext;
189
+ this.audioNext += AAC_FRAME * audio.length;
190
+ fragments.push({ track: AUDIO_TRACK, baseTime, samples: audio.map((a) => ({ duration: AAC_FRAME, data: a.data, key: true })) });
191
+ if (!video.length) durationMs = (AAC_FRAME * audio.length * 1000) / rate;
192
+ }
193
+ const sequence = this.sequence++;
194
+ const cut: Cut = { sequence, bytes: mediaSegment(sequence + 1, fragments), start: startMs / 1000, duration: durationMs / 1000 };
195
+ if (this.freshInit) {
196
+ this.freshInit = false;
197
+ const video = this.avc ? { codec: this.avc.codec, width: this.avc.width || Number(this.meta.width) || 0, height: this.avc.height || Number(this.meta.height) || 0, ...(frameRate(this.meta) ? { frameRate: frameRate(this.meta)! } : {}) } : undefined;
198
+ const audioInfo = this.aac ? { codec: this.aac.codec, sampleRate: this.aac.sampleRate, channels: this.aac.channels } : undefined;
199
+ cut.init = {
200
+ bytes: initSegment({ ...(this.avc ? { video: { ...this.avc, width: video!.width, height: video!.height } } : {}), ...(this.aac ? { audio: this.aac } : {}) }),
201
+ ...(video ? { video } : {}),
202
+ ...(audioInfo ? { audio: audioInfo } : {}),
203
+ };
204
+ }
205
+ return cut;
206
+ }
207
+ }
208
+
209
+ function frameRate(meta: Record<string, unknown>): number | undefined {
210
+ const n = Number(meta.framerate ?? meta.fps);
211
+ return Number.isFinite(n) && n > 0 ? n : undefined;
212
+ }
213
+
214
+ function same(a: Uint8Array, b: Uint8Array): boolean {
215
+ if (a.length !== b.length) return false;
216
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
217
+ return true;
218
+ }
@@ -0,0 +1,105 @@
1
+ // LIVE MEDIA's contract (docs/contributing/architecture.md, "Other wires": live media): what a pack declares (the
2
+ // manifest's `live`), what its dialect writes (`semantics/live.ts`, one `LiveEngine` per ingest), and what its screens
3
+ // read (`ctx.live`). The kernel holds the wire (RTMP in, HLS out) and the recording; the pack holds what a stream key
4
+ // means, the vendor's stream or broadcast it opens, and the state moves when it starts and ends. Types only.
5
+
6
+ /** An ingest the vendor takes (the manifest's `live`): its id, which names the engine `semantics/live.ts` exports, and
7
+ * the RTMP application a publisher connects to (`app` in Twitch's `rtmp://<ingest-server>/app/<stream-key>`, `x` in an
8
+ * X Media Studio Producer source's URL). */
9
+ export type LiveDecl = { id: string; app: string; note: string; source?: string };
10
+
11
+ /** What a publisher asks: the app it connected to, the stream key it publishes (RTMP's `publish` name, its query taken
12
+ * off), that query's parameters (Twitch's `bandwidthtest=true`), and who it is. */
13
+ export type LivePublish = {
14
+ app: string;
15
+ key: string;
16
+ query: Record<string, string>;
17
+ /** the publisher's address, as the listener saw it */
18
+ peer: string;
19
+ /** the connect command's `tcUrl`, `flashVer` and the publish type (`live`, `record`, `append`), as sent */
20
+ tcUrl?: string;
21
+ flashVer?: string;
22
+ type?: string;
23
+ };
24
+
25
+ /** RTMP's refusals of a publish, sent as the publisher's `onStatus` error before the connection closes. */
26
+ export type LiveRefusal = 'NetStream.Publish.BadName' | 'NetStream.Publish.Denied';
27
+
28
+ /** The dialect's ruling on a publish: the recording it goes to (the pack's own id for the vendor's stream or broadcast:
29
+ * letters, digits, `.`, `_`, `-`), or the vendor's refusal in its own words. */
30
+ export type LiveAdmission =
31
+ | {
32
+ stream: string;
33
+ /** the target segment duration, in seconds (default 2): a segment is cut at the first keyframe after it */
34
+ segmentSeconds?: number;
35
+ /** how many segments the live playlist lists (default 6) */
36
+ window?: number;
37
+ /** how many segments the recording keeps (default all): an older one's bytes are removed and its subject deleted */
38
+ retain?: number;
39
+ }
40
+ | { refused: LiveRefusal; description: string };
41
+
42
+ export type LiveVideo = {
43
+ /** the RFC 6381 codec string (`avc1.64001f`) */
44
+ codec: string;
45
+ width: number;
46
+ height: number;
47
+ /** frames per second, as the publisher's metadata names it */
48
+ frameRate?: number;
49
+ };
50
+ export type LiveAudio = { codec: string; sampleRate: number; channels: number };
51
+
52
+ /** A recording as the kernel keeps it: the stream the dialect admitted, what was published to it, and its state. */
53
+ export type LiveRecording = {
54
+ stream: string;
55
+ /** the ingest's app and the key the last publish named */
56
+ app: string;
57
+ key: string;
58
+ /** `publishing` from the publish's admission until its publisher stops; `ended` after */
59
+ state: 'publishing' | 'ended';
60
+ /** World times: the first publish's admission, and the last publish's end */
61
+ startedAt: string;
62
+ endedAt?: string;
63
+ /** how many publishes the recording holds (a publisher that reconnected to the same stream adds one) */
64
+ publishes: number;
65
+ /** the segments kept, and the seconds of media they hold */
66
+ segments: number;
67
+ duration: number;
68
+ video?: LiveVideo;
69
+ audio?: LiveAudio;
70
+ /** the publisher's `onMetaData`, as sent (its encoder, bitrates, frame rate) */
71
+ metadata?: Record<string, unknown>;
72
+ };
73
+
74
+ /** An ingest's dialect (`semantics/live.ts`, one export per id the manifest's `live` declares), over the handler
75
+ * contract's context. Its writes are the vendor's own moves, recorded as any write. */
76
+ export type LiveEngine<C> = {
77
+ /** A publisher asks to publish `ask.key`: the stream it goes to, or the vendor's refusal. */
78
+ publish(ctx: C, ask: LivePublish): LiveAdmission | Promise<LiveAdmission>;
79
+ /** The publish's first segment is playable: the vendor's move (a stream live, a broadcast running). */
80
+ started?(ctx: C, recording: LiveRecording): void | Promise<void>;
81
+ /** The publisher stopped (unpublished, deleted its stream, or hung up), its last segment written: the vendor's move
82
+ * (the stream offline, the broadcast ended). */
83
+ ended?(ctx: C, recording: LiveRecording): void | Promise<void>;
84
+ };
85
+
86
+ /** How a playlist is answered. */
87
+ export type LiveServeOptions = {
88
+ /** the segments a live playlist lists: a count (the admission's window, else 6), or `all` (an event playlist) */
89
+ window?: number | 'all';
90
+ /** the URI a playlist writes for one of the recording's files (`init-0.mp4`, `seg-12.m4s`, `index.m3u8`); absent,
91
+ * the file's name, relative to the playlist */
92
+ uri?: (file: string) => string;
93
+ };
94
+
95
+ /** What a handler and a screen read of the World's recordings (`ctx.live`). */
96
+ export type LiveAccess = {
97
+ /** The recording `stream` names, or undefined when nothing was published to it. */
98
+ recording(stream: string): LiveRecording | undefined;
99
+ /** Every recording of this twin, in the order they were first published. */
100
+ recordings(): LiveRecording[];
101
+ /** The recording's `file` as a media host answers it: `index.m3u8` (the media playlist: the live window while it is
102
+ * published, the whole recording once it ended), `master.m3u8` (a multivariant playlist naming that one rendition),
103
+ * `init-<n>.mp4` or `seg-<n>.m4s`; 404 for anything else, or when the recording or the file is not there. */
104
+ serve(stream: string, file: string, options?: LiveServeOptions): Promise<Response>;
105
+ };
package/src/pack-fetch.ts CHANGED
@@ -27,6 +27,11 @@ import { hostRules } from '../host-routing.cjs';
27
27
  /** A descriptor's host rules, as data. */
28
28
  type PackDescriptorHosts = ReadonlyArray<HostRule> | undefined;
29
29
  import { socketUpgrade, type SocketEngine } from './sockets.ts';
30
+ import { liveIngest } from './live/ingest.ts';
31
+ import type { LiveEngine } from './live/types.ts';
32
+ import { readResourceBlob, removeResourceBlob, writeResourceBlob } from './resource-blob.ts';
33
+ import { resourcesOfType } from './derived-core.ts';
34
+ import { worldNow } from './world-clock.ts';
30
35
  import type { WebSocketUpgrade, WebSocketPeer } from './serve-http.ts';
31
36
  import { MACHINE_POOL, POOL_KINDS, resetFakePool } from './machines.ts';
32
37
  import { applyTwinWrite } from './serve.ts';
@@ -71,6 +76,8 @@ export type PackParts = {
71
76
  sockets?: Record<string, SocketEngine<HandlerContext>>;
72
77
  /** `semantics/board.ts`: the vendor's frames on the World's board, read from its state (`GET /twin/board`). */
73
78
  board?: (ctx: HandlerContext) => BoardFrame[] | Promise<BoardFrame[]>;
79
+ /** `semantics/live.ts`: each ingest's dialect, by the id the manifest's `live` gives it (live/ingest.ts). */
80
+ live?: Record<string, LiveEngine<HandlerContext>>;
74
81
  };
75
82
 
76
83
  /** A pack's scenario (`semantics/scenario.ts` exports `scenario`): the adapter the kernel's engine reads the World's
@@ -86,6 +93,7 @@ export type LanePart = {
86
93
  /** the hosts the lane's built content screens name: its vendor's router sends `<base>/@<host>/` there */
87
94
  contentHosts?: ReadonlyArray<string>;
88
95
  upgrade?: WebSocketUpgrade;
96
+ ingest?: import('./twin-fetch.ts').TwinStream;
89
97
  };
90
98
  manifest: { doors?: ReadonlyArray<{ method: string; path: string }> };
91
99
  };
@@ -608,6 +616,22 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
608
616
  // a turn a session speaks (a voice agent's reply) is the World's scenario's, as a turn over HTTP is
609
617
  async (asked) => engineOf()?.serve(asked)) : undefined;
610
618
  const upgrade = laneUpgrades(parts.lanes ?? {}, ownUpgrade);
619
+ // the broadcasts the vendor takes over RTMP: a publisher's connection, its publish ruled by the pack's dialect over a
620
+ // context of its own (operation `live:<id>`), its recording kept as the World's bookkeeping and blobs (live/ingest.ts)
621
+ const ownIngest = m.live?.length ? liveIngest<HandlerContext>({
622
+ world: `${m.service}\u0000${options.root ?? ''}`, decls: m.live, engines: parts.live ?? {}, readOnly: options.readOnly ?? false,
623
+ open: (decl, ask) => open(new Request(`http://live.${m.vendor}.invalid/${encodeURIComponent(decl.app)}`, { method: 'POST', headers: { 'x-forwarded-for': ask.peer } }), `live:${decl.id}`, 'POST', `/${decl.app}`, {}),
624
+ run: (fn) => runWithCorrelationId(`live_${globalThis.crypto.randomUUID()}`, () => runAsVendorMove(fn)),
625
+ store: {
626
+ rows: (type) => resourcesOfType(m.service, type, options.root) as unknown as Array<Record<string, unknown>>,
627
+ blob: (key) => readResourceBlob(m.service, key, options.root),
628
+ putBlob: (key, bytes) => writeResourceBlob(m.service, key, bytes, options.root),
629
+ removeBlob: (key) => removeResourceBlob(m.service, key, options.root),
630
+ now: () => (options.clock ?? worldNow)(),
631
+ record: async (type, id, fields, operation) => { await applyTwinWrite(m.service, { operation, subjectType: type, subjectId: id, fields, occurredAt: (options.clock ?? worldNow)(), actor: { kind: 'system' } }, options.root); },
632
+ },
633
+ }) : undefined;
634
+ const ingest = ownIngest ?? Object.values(parts.lanes ?? {}).find((l) => l.fetch.ingest)?.fetch.ingest;
611
635
  // what serves each operation: the pack's own, and each lane's, each unit's under its own name
612
636
  const ownersByUnit = (): Record<string, Record<string, DerivedOwner>> => ({
613
637
  '': {
@@ -617,7 +641,7 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
617
641
  ...lanesOwners(parts.lanes ?? {}),
618
642
  });
619
643
  const owners = (): Record<string, DerivedOwner> => flatOwners(ownersByUnit());
620
- return derivedRequestScopes(m, Object.assign(served, { owners, ownersByUnit, serves, advanceClock, workspaces, boards: Boolean(parts.board), maxRequestBodySize: m.descriptor?.maxRequestBodySize, draws: drawsUrl, contentHosts, ...(upgrade ? { upgrade } : {}) }), options.readOnly);
644
+ return derivedRequestScopes(m, Object.assign(served, { owners, ownersByUnit, serves, advanceClock, workspaces, boards: Boolean(parts.board), maxRequestBodySize: m.descriptor?.maxRequestBodySize, draws: drawsUrl, contentHosts, ...(upgrade ? { upgrade } : {}), ...(ingest ? { ingest } : {}) }), options.readOnly);
621
645
  }
622
646
 
623
647
  /** The vendor's gateway over its lanes: the lane a request goes to (a door to the lane that declares it, then the first
@@ -715,6 +739,8 @@ function laneUpgrades(lanes: Record<string, LanePart>, own?: WebSocketUpgrade):
715
739
 
716
740
  export function createVendorFetch(manifest: VendorManifest, lanes: Record<string, LanePart>): DerivedFetch {
717
741
  const upgrade = laneUpgrades(lanes);
742
+ // a lane's live ingest is the vendor's (one RTMP listener beside the vendor's HTTP host)
743
+ const ingest = Object.values(lanes).find((l) => l.fetch.ingest)?.fetch.ingest;
718
744
  const lane = laneRouter(manifest.vendor, manifest.lanes, lanes);
719
745
  return Object.assign(async (request: Request): Promise<Response> => {
720
746
  for (const part of Object.values(lanes)) await (part.fetch as typeof part.fetch & { advanceClock?: (request: Request) => Promise<void> }).advanceClock?.(request);
@@ -744,5 +770,5 @@ export function createVendorFetch(manifest: VendorManifest, lanes: Record<string
744
770
  }
745
771
  const to = lane(request);
746
772
  return to ? to(request) : Response.json({ error: `${manifest.vendor}: no lane serves ${request.method} ${path}` }, { status: 404 });
747
- }, { maxRequestBodySize: manifest.descriptor?.maxRequestBodySize, owners: () => flatOwners(lanesOwners(lanes)), ownersByUnit: () => lanesOwners(lanes), workspaces: () => lanesWorkspaces(lanes), ...(upgrade ? { upgrade } : {}) });
773
+ }, { maxRequestBodySize: manifest.descriptor?.maxRequestBodySize, owners: () => flatOwners(lanesOwners(lanes)), ownersByUnit: () => lanesOwners(lanes), workspaces: () => lanesWorkspaces(lanes), ...(upgrade ? { upgrade } : {}), ...(ingest ? { ingest } : {}) });
748
774
  }
@@ -239,7 +239,7 @@ export type TwinPack = {
239
239
  prismaAdapter?: { adapter: string; export: string; urlEnv: string };
240
240
  managedService?: { kind: 'redis' | 'mongodb'; scheme: string; note: string };
241
241
  managedDatabase?: { kind: 'postgres'; arg: string; note: string };
242
- sidePorts?: Array<{ name: string; option: string; flag: string; path?: string; note: string }>;
242
+ sidePorts?: Array<{ name: string; option: string; flag: string; path?: string; scheme?: string; note: string }>;
243
243
  stateSystem?: StateSystemAdapters;
244
244
  /** The pack has no vendor-backed half, and why (copied from the manifest by packOf). */
245
245
  vendorBacked?: { none: string };
package/src/serve-http.ts CHANGED
@@ -114,14 +114,39 @@ type NodeStream = typeof import('node:stream');
114
114
  * is Bun's alone; Node's ESM has none (found 2026-09-07 by the pages running under node). */
115
115
  export function nodeBuiltin<T = unknown>(name: string): T { return builtin<T>(name); }
116
116
 
117
+ /** What `serveStream` throws when the runtime has no TCP layer to listen with at all (no `process.getBuiltinModule`, or
118
+ * no `node:net` behind it), as opposed to a listen that failed (EADDRINUSE, EACCES), which it throws as it came. A
119
+ * server that can serve without its byte-stream listener (a live ingest beside its HTTP host) falls back on this alone. */
120
+ export class StreamListenerUnavailable extends Error {}
121
+
122
+ /** A stream connection's backlog (whole messages waiting for the twin's work) past which its socket is paused. */
123
+ const STREAM_HIGH_WATER = 4 * 1024 * 1024;
124
+
117
125
  /** A raw-TCP twin's listener (the seam's byte-stream half): its `create<Name>TwinStream` served on a TCP port, each
118
126
  * socket one connection, on loopback unless the caller names a hostname. Runtime-neutral, as `serveHttp` is: Node's
119
- * `net` reached at call time, never imported. */
120
- export async function serveStream(options: { port?: number; hostname?: string; stream: TwinStream }): Promise<{ port: number; stop: () => void }> {
121
- const net = builtin<typeof import('node:net')>('node:net');
127
+ * `net` reached at call time, never imported. A stream that declares `maxConnections` (a live ingest) is held to it. */
128
+ export async function serveStream(options: { port?: number; hostname?: string; stream: TwinStream; maxConnections?: number }): Promise<{ port: number; stop: () => void }> {
129
+ const get = (process as { getBuiltinModule?: (id: string) => unknown }).getBuiltinModule;
130
+ const net = typeof get === 'function' ? get('node:net') as typeof import('node:net') | undefined : undefined;
131
+ if (!net || typeof net.createServer !== 'function') throw new StreamListenerUnavailable('this runtime has no TCP listener (node:net)');
132
+ const cap = options.maxConnections ?? (options.stream as { maxConnections?: number }).maxConnections;
133
+ let open = 0;
122
134
  const server = net.createServer((socket) => {
135
+ // past its cap a connection is closed before the stream sees it, so connections that never get anywhere cannot pile up
136
+ if (cap !== undefined && open >= cap) { socket.destroy(); return; }
137
+ open += 1;
138
+ socket.once('close', () => { open -= 1; });
123
139
  const conn = options.stream({ write: (bytes) => { socket.write(bytes); }, end: () => { socket.end(); } }, socket.remoteAddress ?? '127.0.0.1');
124
- socket.on('data', (chunk: Buffer) => conn.data(new Uint8Array(chunk)));
140
+ // a connection that says what waits (`backlog`: whole messages its work has not reached) is read no further past the
141
+ // high-water mark until that work has settled, so a peer faster than the twin (a live ingest pushed faster than real
142
+ // time) waits in TCP, not here. It is read again once settled, whatever else it holds: only more bytes can finish an
143
+ // unfinished message, and settling resolves once, so nothing re-arms in a loop
144
+ let paused = false;
145
+ const resume = (): void => { paused = false; if (!socket.destroyed) socket.resume(); };
146
+ socket.on('data', (chunk: Buffer) => {
147
+ conn.data(new Uint8Array(chunk));
148
+ if (!paused && conn.backlog && conn.backlog() > STREAM_HIGH_WATER) { paused = true; socket.pause(); void conn.settled().then(resume, resume); }
149
+ });
125
150
  socket.on('close', () => conn.close());
126
151
  socket.on('error', () => conn.close());
127
152
  });
package/src/twin-fetch.ts CHANGED
@@ -100,9 +100,11 @@ export function twinVendorUrl(request: Request, vendorUrl: string): string {
100
100
  * it over whatever carries the bytes — a local listener's socket, or a WebSocket to a hosted World.
101
101
  * The pack owns framing and protocol; the host owns the transport. `open` greets through the sink;
102
102
  * `data` hands the connection bytes in arrival order; `settled` resolves when every byte handed so
103
- * far has been answered; `close` is the peer going away. */
103
+ * far has been answered; `close` is the peer going away. A connection whose work can fall behind its bytes (a live
104
+ * ingest writing segments) says how many bytes wait in `backlog`, and a listener that can pause its transport does so
105
+ * past a high-water mark until `settled` (serve-http.ts, `serveStream`). */
104
106
  export type TwinStreamSink = { write(bytes: Uint8Array): void; end(): void };
105
- export type TwinStreamConnection = { data(chunk: Uint8Array): void; settled(): Promise<void>; close(): void };
107
+ export type TwinStreamConnection = { data(chunk: Uint8Array): void; settled(): Promise<void>; close(): void; backlog?(): number };
106
108
  export type TwinStream = (sink: TwinStreamSink, peer: string) => TwinStreamConnection;
107
109
 
108
110
  /** The request scopes a pack wrapped in `withRequestScopes` enforces, advertised on its `GET /twin` as