@volter/world-core 3.0.150 → 3.0.152

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 (51) hide show
  1. package/dist/inject.cjs +16 -4
  2. package/dist/src/derived-core.d.ts +8 -0
  3. package/dist/src/derived-core.js +2 -0
  4. package/dist/src/derived.d.ts +4 -0
  5. package/dist/src/index.d.ts +3 -1
  6. package/dist/src/index.js +2 -1
  7. package/dist/src/live/access.d.ts +22 -0
  8. package/dist/src/live/access.js +83 -0
  9. package/dist/src/live/amf0.d.ts +7 -0
  10. package/dist/src/live/amf0.js +122 -0
  11. package/dist/src/live/flv.d.ts +42 -0
  12. package/dist/src/live/flv.js +148 -0
  13. package/dist/src/live/fmp4.d.ts +25 -0
  14. package/dist/src/live/fmp4.js +96 -0
  15. package/dist/src/live/hls.d.ts +22 -0
  16. package/dist/src/live/hls.js +46 -0
  17. package/dist/src/live/index.d.ts +9 -0
  18. package/dist/src/live/index.js +8 -0
  19. package/dist/src/live/ingest.d.ts +25 -0
  20. package/dist/src/live/ingest.js +229 -0
  21. package/dist/src/live/rtmp.d.ts +35 -0
  22. package/dist/src/live/rtmp.js +456 -0
  23. package/dist/src/live/segmenter.d.ts +57 -0
  24. package/dist/src/live/segmenter.js +204 -0
  25. package/dist/src/live/types.d.ts +102 -0
  26. package/dist/src/live/types.js +5 -0
  27. package/dist/src/pack-fetch.d.ts +4 -0
  28. package/dist/src/pack-fetch.js +24 -2
  29. package/dist/src/packRegistry.d.ts +1 -0
  30. package/dist/src/serve-http.d.ts +7 -1
  31. package/dist/src/serve-http.js +36 -3
  32. package/dist/src/twin-fetch.d.ts +4 -1
  33. package/inject.cjs +16 -4
  34. package/package.json +5 -1
  35. package/src/derived-core.ts +10 -0
  36. package/src/derived.ts +4 -0
  37. package/src/index.ts +4 -1
  38. package/src/live/access.ts +89 -0
  39. package/src/live/amf0.ts +80 -0
  40. package/src/live/flv.ts +145 -0
  41. package/src/live/fmp4.ts +102 -0
  42. package/src/live/hls.ts +59 -0
  43. package/src/live/index.ts +13 -0
  44. package/src/live/ingest.ts +210 -0
  45. package/src/live/rtmp.ts +375 -0
  46. package/src/live/segmenter.ts +218 -0
  47. package/src/live/types.ts +105 -0
  48. package/src/pack-fetch.ts +28 -2
  49. package/src/packRegistry.ts +1 -1
  50. package/src/serve-http.ts +29 -4
  51. package/src/twin-fetch.ts +4 -2
@@ -0,0 +1,204 @@
1
+ import { AUDIO_TRACK, initSegment, mediaSegment, VIDEO_TIMESCALE, VIDEO_TRACK } from "./fmp4.js";
2
+ const AAC_FRAME = 1024;
3
+ /** The longest a stream may go without a keyframe: ten target durations, at least 20 s, past OBS's automatic interval
4
+ * of 250 frames (8.3 s at 30 fps, 10.4 s at 24). */
5
+ const MIN_KEYFRAME_CEILING_MS = 20_000;
6
+ /** What one segment under way may hold: at a Twitch-like 8 Mbit/s, 20 s is 20 MB. */
7
+ const MAX_SEGMENT_BYTES = 64 * 1024 * 1024;
8
+ const MAX_SEGMENT_FRAMES = 10_000;
9
+ /** A stream the segmenter will not hold: its publish is ended with the reason (ingest.ts). */
10
+ export class LiveOverrun extends Error {
11
+ }
12
+ export class Segmenter {
13
+ options;
14
+ avc;
15
+ aac;
16
+ meta = {};
17
+ freshInit = true;
18
+ video = [];
19
+ audio = [];
20
+ start;
21
+ keyed = false;
22
+ audioNext;
23
+ lastVideoDuration = 33;
24
+ sequence = 0;
25
+ /** what the segment under way holds, and since when video has come with no keyframe to start on */
26
+ bytes = 0;
27
+ frames = 0;
28
+ unkeyedSince;
29
+ constructor(options) {
30
+ this.options = options;
31
+ this.sequence = options.firstSequence;
32
+ }
33
+ /** The publisher's metadata (`onMetaData`): the size and rate a stream's SPS does not say, and whether video comes. */
34
+ metadata(meta) { this.meta = { ...this.meta, ...meta }; }
35
+ videoConfig(config) {
36
+ if (this.avc && same(this.avc.record, config.record))
37
+ return [];
38
+ const out = this.flush();
39
+ this.avc = config;
40
+ this.freshInit = true;
41
+ this.keyed = false;
42
+ return out;
43
+ }
44
+ audioConfig(config) {
45
+ if (this.aac && same(this.aac.asc, config.asc))
46
+ return [];
47
+ const out = this.aac ? this.flush() : [];
48
+ this.aac = config;
49
+ this.freshInit = true;
50
+ return out;
51
+ }
52
+ /** A video frame at its decode time: the segment it closes, if it closes one. */
53
+ videoFrame(timestamp, key, cts, data) {
54
+ if (!this.avc)
55
+ return [];
56
+ const dts = timestamp + this.options.offsetMs;
57
+ const out = [];
58
+ if (!this.keyed) {
59
+ // a stream is played from a keyframe: what comes before the first is undecodable, and audio before it unheard
60
+ if (!key) {
61
+ this.unkeyedSince ??= dts;
62
+ if (dts - this.unkeyedSince >= this.keyframeCeiling())
63
+ throw this.noKeyframe();
64
+ return [];
65
+ }
66
+ this.keyed = true;
67
+ this.unkeyedSince = undefined;
68
+ if (this.video.length || this.audioOnlyStarted())
69
+ out.push(...this.flush());
70
+ this.start = dts;
71
+ this.audio = this.audio.filter((a) => a.dts >= dts);
72
+ }
73
+ else if (key && this.start !== undefined && dts - this.start >= this.options.targetMs) {
74
+ const cut = this.cut(dts);
75
+ if (cut)
76
+ out.push(cut);
77
+ this.start = dts;
78
+ }
79
+ this.within(dts, data.length, key);
80
+ this.video.push({ dts, cts, key, data });
81
+ this.bytes += data.length;
82
+ this.frames += 1;
83
+ return out;
84
+ }
85
+ audioFrame(timestamp, data) {
86
+ if (!this.aac || data.length === 0)
87
+ return [];
88
+ const dts = timestamp + this.options.offsetMs;
89
+ if (this.expectsVideo()) {
90
+ if (!this.keyed)
91
+ return [];
92
+ this.within(dts, data.length);
93
+ this.audio.push({ dts, data });
94
+ this.bytes += data.length;
95
+ this.frames += 1;
96
+ return [];
97
+ }
98
+ // audio alone: cut on its own time
99
+ const out = [];
100
+ if (this.start === undefined)
101
+ this.start = dts;
102
+ else if (dts - this.start >= this.options.targetMs) {
103
+ const cut = this.cut(dts);
104
+ if (cut)
105
+ out.push(cut);
106
+ this.start = dts;
107
+ }
108
+ this.within(dts, data.length);
109
+ this.audio.push({ dts, data });
110
+ this.bytes += data.length;
111
+ this.frames += 1;
112
+ return out;
113
+ }
114
+ /** The segment under way, closed (the publish's end, or a configuration change). */
115
+ flush() {
116
+ const cut = this.cut(undefined);
117
+ this.video = [];
118
+ this.audio = [];
119
+ this.start = undefined;
120
+ this.bytes = 0;
121
+ this.frames = 0;
122
+ return cut ? [cut] : [];
123
+ }
124
+ keyframeCeiling() { return Math.max(MIN_KEYFRAME_CEILING_MS, 10 * this.options.targetMs); }
125
+ noKeyframe() {
126
+ 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).`);
127
+ }
128
+ /** A frame the segment under way may still take, or the refusal past its ceilings. */
129
+ within(dts, size, key = false) {
130
+ if (!key && this.start !== undefined && dts - this.start >= this.keyframeCeiling())
131
+ throw this.noKeyframe();
132
+ if (this.bytes + size > MAX_SEGMENT_BYTES || this.frames + 1 > MAX_SEGMENT_FRAMES)
133
+ throw new LiveOverrun(`A segment passed ${MAX_SEGMENT_BYTES} bytes or ${MAX_SEGMENT_FRAMES} frames before a keyframe closed it.`);
134
+ }
135
+ expectsVideo() { return this.avc !== undefined || this.meta.videocodecid !== undefined; }
136
+ audioOnlyStarted() { return !this.expectsVideo() && this.audio.length > 0; }
137
+ cut(at) {
138
+ const video = this.video;
139
+ const audio = at === undefined ? this.audio : this.audio.filter((a) => a.dts < at);
140
+ this.audio = at === undefined ? [] : this.audio.filter((a) => a.dts >= at);
141
+ this.video = [];
142
+ // what the next segment already holds: the audio past the cut
143
+ this.bytes = this.audio.reduce((n, a) => n + a.data.length, 0);
144
+ this.frames = this.audio.length;
145
+ if (video.length === 0 && audio.length === 0)
146
+ return undefined;
147
+ const fragments = [];
148
+ let startMs;
149
+ let durationMs;
150
+ if (video.length) {
151
+ const samples = video.map((s, i) => {
152
+ const next = i + 1 < video.length ? video[i + 1].dts : at;
153
+ const ms = next === undefined ? this.lastVideoDuration : Math.max(1, next - s.dts);
154
+ if (next !== undefined)
155
+ this.lastVideoDuration = ms;
156
+ return { duration: Math.round(ms * 90), data: s.data, key: s.key, cts: Math.round(s.cts * 90) };
157
+ });
158
+ startMs = video[0].dts;
159
+ durationMs = samples.reduce((n, s) => n + s.duration, 0) / 90;
160
+ fragments.push({ track: VIDEO_TRACK, baseTime: Math.round(startMs * (VIDEO_TIMESCALE / 1000)), samples });
161
+ }
162
+ else {
163
+ startMs = audio[0].dts;
164
+ durationMs = 0;
165
+ }
166
+ if (audio.length && this.aac) {
167
+ const rate = this.aac.sampleRate;
168
+ // a running sample clock, resynchronised to the publisher's timestamps when they drift past 100 ms (a gap)
169
+ const expected = Math.round(audio[0].dts * rate / 1000);
170
+ if (this.audioNext === undefined || Math.abs(this.audioNext - expected) > rate / 10)
171
+ this.audioNext = expected;
172
+ const baseTime = this.audioNext;
173
+ this.audioNext += AAC_FRAME * audio.length;
174
+ fragments.push({ track: AUDIO_TRACK, baseTime, samples: audio.map((a) => ({ duration: AAC_FRAME, data: a.data, key: true })) });
175
+ if (!video.length)
176
+ durationMs = (AAC_FRAME * audio.length * 1000) / rate;
177
+ }
178
+ const sequence = this.sequence++;
179
+ const cut = { sequence, bytes: mediaSegment(sequence + 1, fragments), start: startMs / 1000, duration: durationMs / 1000 };
180
+ if (this.freshInit) {
181
+ this.freshInit = false;
182
+ 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;
183
+ const audioInfo = this.aac ? { codec: this.aac.codec, sampleRate: this.aac.sampleRate, channels: this.aac.channels } : undefined;
184
+ cut.init = {
185
+ bytes: initSegment({ ...(this.avc ? { video: { ...this.avc, width: video.width, height: video.height } } : {}), ...(this.aac ? { audio: this.aac } : {}) }),
186
+ ...(video ? { video } : {}),
187
+ ...(audioInfo ? { audio: audioInfo } : {}),
188
+ };
189
+ }
190
+ return cut;
191
+ }
192
+ }
193
+ function frameRate(meta) {
194
+ const n = Number(meta.framerate ?? meta.fps);
195
+ return Number.isFinite(n) && n > 0 ? n : undefined;
196
+ }
197
+ function same(a, b) {
198
+ if (a.length !== b.length)
199
+ return false;
200
+ for (let i = 0; i < a.length; i++)
201
+ if (a[i] !== b[i])
202
+ return false;
203
+ return true;
204
+ }
@@ -0,0 +1,102 @@
1
+ /** An ingest the vendor takes (the manifest's `live`): its id, which names the engine `semantics/live.ts` exports, and
2
+ * the RTMP application a publisher connects to (`app` in Twitch's `rtmp://<ingest-server>/app/<stream-key>`, `x` in an
3
+ * X Media Studio Producer source's URL). */
4
+ export type LiveDecl = {
5
+ id: string;
6
+ app: string;
7
+ note: string;
8
+ source?: string;
9
+ };
10
+ /** What a publisher asks: the app it connected to, the stream key it publishes (RTMP's `publish` name, its query taken
11
+ * off), that query's parameters (Twitch's `bandwidthtest=true`), and who it is. */
12
+ export type LivePublish = {
13
+ app: string;
14
+ key: string;
15
+ query: Record<string, string>;
16
+ /** the publisher's address, as the listener saw it */
17
+ peer: string;
18
+ /** the connect command's `tcUrl`, `flashVer` and the publish type (`live`, `record`, `append`), as sent */
19
+ tcUrl?: string;
20
+ flashVer?: string;
21
+ type?: string;
22
+ };
23
+ /** RTMP's refusals of a publish, sent as the publisher's `onStatus` error before the connection closes. */
24
+ export type LiveRefusal = 'NetStream.Publish.BadName' | 'NetStream.Publish.Denied';
25
+ /** The dialect's ruling on a publish: the recording it goes to (the pack's own id for the vendor's stream or broadcast:
26
+ * letters, digits, `.`, `_`, `-`), or the vendor's refusal in its own words. */
27
+ export type LiveAdmission = {
28
+ stream: string;
29
+ /** the target segment duration, in seconds (default 2): a segment is cut at the first keyframe after it */
30
+ segmentSeconds?: number;
31
+ /** how many segments the live playlist lists (default 6) */
32
+ window?: number;
33
+ /** how many segments the recording keeps (default all): an older one's bytes are removed and its subject deleted */
34
+ retain?: number;
35
+ } | {
36
+ refused: LiveRefusal;
37
+ description: string;
38
+ };
39
+ export type LiveVideo = {
40
+ /** the RFC 6381 codec string (`avc1.64001f`) */
41
+ codec: string;
42
+ width: number;
43
+ height: number;
44
+ /** frames per second, as the publisher's metadata names it */
45
+ frameRate?: number;
46
+ };
47
+ export type LiveAudio = {
48
+ codec: string;
49
+ sampleRate: number;
50
+ 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
+ /** An ingest's dialect (`semantics/live.ts`, one export per id the manifest's `live` declares), over the handler
74
+ * contract's context. Its writes are the vendor's own moves, recorded as any write. */
75
+ export type LiveEngine<C> = {
76
+ /** A publisher asks to publish `ask.key`: the stream it goes to, or the vendor's refusal. */
77
+ publish(ctx: C, ask: LivePublish): LiveAdmission | Promise<LiveAdmission>;
78
+ /** The publish's first segment is playable: the vendor's move (a stream live, a broadcast running). */
79
+ started?(ctx: C, recording: LiveRecording): void | Promise<void>;
80
+ /** The publisher stopped (unpublished, deleted its stream, or hung up), its last segment written: the vendor's move
81
+ * (the stream offline, the broadcast ended). */
82
+ ended?(ctx: C, recording: LiveRecording): void | Promise<void>;
83
+ };
84
+ /** How a playlist is answered. */
85
+ export type LiveServeOptions = {
86
+ /** the segments a live playlist lists: a count (the admission's window, else 6), or `all` (an event playlist) */
87
+ window?: number | 'all';
88
+ /** the URI a playlist writes for one of the recording's files (`init-0.mp4`, `seg-12.m4s`, `index.m3u8`); absent,
89
+ * the file's name, relative to the playlist */
90
+ uri?: (file: string) => string;
91
+ };
92
+ /** What a handler and a screen read of the World's recordings (`ctx.live`). */
93
+ export type LiveAccess = {
94
+ /** The recording `stream` names, or undefined when nothing was published to it. */
95
+ recording(stream: string): LiveRecording | undefined;
96
+ /** Every recording of this twin, in the order they were first published. */
97
+ recordings(): LiveRecording[];
98
+ /** The recording's `file` as a media host answers it: `index.m3u8` (the media playlist: the live window while it is
99
+ * published, the whole recording once it ended), `master.m3u8` (a multivariant playlist naming that one rendition),
100
+ * `init-<n>.mp4` or `seg-<n>.m4s`; 404 for anything else, or when the recording or the file is not there. */
101
+ serve(stream: string, file: string, options?: LiveServeOptions): Promise<Response>;
102
+ };
@@ -0,0 +1,5 @@
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
+ export {};
@@ -3,6 +3,7 @@ import { type DerivedFetch, type DerivedSurface, type BoardFrame } from './deriv
3
3
  import { type PackScenarioAdapter } from './scenario.js';
4
4
  import type { ApplicationStandIn } from './events.js';
5
5
  import { type SocketEngine } from './sockets.js';
6
+ import type { LiveEngine } from './live/types.js';
6
7
  import type { WebSocketUpgrade } from './serve-http.js';
7
8
  import { type GraphqlPart } from './graphql-wire.js';
8
9
  /** What a pack declares and writes, and nothing it assembles itself. */
@@ -43,6 +44,8 @@ export type PackParts = {
43
44
  sockets?: Record<string, SocketEngine<HandlerContext>>;
44
45
  /** `semantics/board.ts`: the vendor's frames on the World's board, read from its state (`GET /twin/board`). */
45
46
  board?: (ctx: HandlerContext) => BoardFrame[] | Promise<BoardFrame[]>;
47
+ /** `semantics/live.ts`: each ingest's dialect, by the id the manifest's `live` gives it (live/ingest.ts). */
48
+ live?: Record<string, LiveEngine<HandlerContext>>;
46
49
  };
47
50
  /** A pack's scenario (`semantics/scenario.ts` exports `scenario`): the adapter the kernel's engine reads the World's
48
51
  * scenario document with, the operations it decides a turn of, and the request an operation is to it (undefined when
@@ -61,6 +64,7 @@ export type LanePart = {
61
64
  /** the hosts the lane's built content screens name: its vendor's router sends `<base>/@<host>/` there */
62
65
  contentHosts?: ReadonlyArray<string>;
63
66
  upgrade?: WebSocketUpgrade;
67
+ ingest?: import('./twin-fetch.js').TwinStream;
64
68
  };
65
69
  manifest: {
66
70
  doors?: ReadonlyArray<{
@@ -23,6 +23,10 @@ import { contentAddressOf } from "./content-route.js";
23
23
  import { getPack } from "./packRegistry.js";
24
24
  import { hostRules } from '../host-routing.cjs';
25
25
  import { socketUpgrade } from "./sockets.js";
26
+ import { liveIngest } from "./live/ingest.js";
27
+ import { readResourceBlob, removeResourceBlob, writeResourceBlob } from "./resource-blob.js";
28
+ import { resourcesOfType } from "./derived-core.js";
29
+ import { worldNow } from "./world-clock.js";
26
30
  import { MACHINE_POOL, POOL_KINDS, resetFakePool } from "./machines.js";
27
31
  import { applyTwinWrite } from "./serve.js";
28
32
  import { graphqlEndpoint } from "./graphql-wire.js";
@@ -609,6 +613,22 @@ export function createPackFetch(parts, options = {}) {
609
613
  // a turn a session speaks (a voice agent's reply) is the World's scenario's, as a turn over HTTP is
610
614
  async (asked) => engineOf()?.serve(asked)) : undefined;
611
615
  const upgrade = laneUpgrades(parts.lanes ?? {}, ownUpgrade);
616
+ // the broadcasts the vendor takes over RTMP: a publisher's connection, its publish ruled by the pack's dialect over a
617
+ // context of its own (operation `live:<id>`), its recording kept as the World's bookkeeping and blobs (live/ingest.ts)
618
+ const ownIngest = m.live?.length ? liveIngest({
619
+ world: `${m.service}\u0000${options.root ?? ''}`, decls: m.live, engines: parts.live ?? {}, readOnly: options.readOnly ?? false,
620
+ 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}`, {}),
621
+ run: (fn) => runWithCorrelationId(`live_${globalThis.crypto.randomUUID()}`, () => runAsVendorMove(fn)),
622
+ store: {
623
+ rows: (type) => resourcesOfType(m.service, type, options.root),
624
+ blob: (key) => readResourceBlob(m.service, key, options.root),
625
+ putBlob: (key, bytes) => writeResourceBlob(m.service, key, bytes, options.root),
626
+ removeBlob: (key) => removeResourceBlob(m.service, key, options.root),
627
+ now: () => (options.clock ?? worldNow)(),
628
+ 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); },
629
+ },
630
+ }) : undefined;
631
+ const ingest = ownIngest ?? Object.values(parts.lanes ?? {}).find((l) => l.fetch.ingest)?.fetch.ingest;
612
632
  // what serves each operation: the pack's own, and each lane's, each unit's under its own name
613
633
  const ownersByUnit = () => ({
614
634
  '': {
@@ -618,7 +638,7 @@ export function createPackFetch(parts, options = {}) {
618
638
  ...lanesOwners(parts.lanes ?? {}),
619
639
  });
620
640
  const owners = () => flatOwners(ownersByUnit());
621
- 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);
641
+ 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);
622
642
  }
623
643
  /** The vendor's gateway over its lanes: the lane a request goes to (a door to the lane that declares it, then the first
624
644
  * route whose conditions all hold, then the default), behind the gateway's CORS; undefined when none takes it. */
@@ -715,6 +735,8 @@ function laneUpgrades(lanes, own) {
715
735
  }
716
736
  export function createVendorFetch(manifest, lanes) {
717
737
  const upgrade = laneUpgrades(lanes);
738
+ // a lane's live ingest is the vendor's (one RTMP listener beside the vendor's HTTP host)
739
+ const ingest = Object.values(lanes).find((l) => l.fetch.ingest)?.fetch.ingest;
718
740
  const lane = laneRouter(manifest.vendor, manifest.lanes, lanes);
719
741
  return Object.assign(async (request) => {
720
742
  for (const part of Object.values(lanes))
@@ -749,5 +771,5 @@ export function createVendorFetch(manifest, lanes) {
749
771
  }
750
772
  const to = lane(request);
751
773
  return to ? to(request) : Response.json({ error: `${manifest.vendor}: no lane serves ${request.method} ${path}` }, { status: 404 });
752
- }, { maxRequestBodySize: manifest.descriptor?.maxRequestBodySize, owners: () => flatOwners(lanesOwners(lanes)), ownersByUnit: () => lanesOwners(lanes), workspaces: () => lanesWorkspaces(lanes), ...(upgrade ? { upgrade } : {}) });
774
+ }, { maxRequestBodySize: manifest.descriptor?.maxRequestBodySize, owners: () => flatOwners(lanesOwners(lanes)), ownersByUnit: () => lanesOwners(lanes), workspaces: () => lanesWorkspaces(lanes), ...(upgrade ? { upgrade } : {}), ...(ingest ? { ingest } : {}) });
753
775
  }
@@ -237,6 +237,7 @@ export type TwinPack = {
237
237
  option: string;
238
238
  flag: string;
239
239
  path?: string;
240
+ scheme?: string;
240
241
  note: string;
241
242
  }>;
242
243
  stateSystem?: StateSystemAdapters;
@@ -50,13 +50,19 @@ export declare function serveHttp(options: ServeHttpOptions): Promise<HttpServer
50
50
  * module that also rides into a browser bundle reaches `node:crypto` with. A bare `require()`
51
51
  * is Bun's alone; Node's ESM has none (found 2026-09-07 by the pages running under node). */
52
52
  export declare function nodeBuiltin<T = unknown>(name: string): T;
53
+ /** What `serveStream` throws when the runtime has no TCP layer to listen with at all (no `process.getBuiltinModule`, or
54
+ * no `node:net` behind it), as opposed to a listen that failed (EADDRINUSE, EACCES), which it throws as it came. A
55
+ * server that can serve without its byte-stream listener (a live ingest beside its HTTP host) falls back on this alone. */
56
+ export declare class StreamListenerUnavailable extends Error {
57
+ }
53
58
  /** A raw-TCP twin's listener (the seam's byte-stream half): its `create<Name>TwinStream` served on a TCP port, each
54
59
  * socket one connection, on loopback unless the caller names a hostname. Runtime-neutral, as `serveHttp` is: Node's
55
- * `net` reached at call time, never imported. */
60
+ * `net` reached at call time, never imported. A stream that declares `maxConnections` (a live ingest) is held to it. */
56
61
  export declare function serveStream(options: {
57
62
  port?: number;
58
63
  hostname?: string;
59
64
  stream: TwinStream;
65
+ maxConnections?: number;
60
66
  }): Promise<{
61
67
  port: number;
62
68
  stop: () => void;
@@ -68,14 +68,47 @@ export async function serveHttp(options) {
68
68
  * module that also rides into a browser bundle reaches `node:crypto` with. A bare `require()`
69
69
  * is Bun's alone; Node's ESM has none (found 2026-09-07 by the pages running under node). */
70
70
  export function nodeBuiltin(name) { return builtin(name); }
71
+ /** What `serveStream` throws when the runtime has no TCP layer to listen with at all (no `process.getBuiltinModule`, or
72
+ * no `node:net` behind it), as opposed to a listen that failed (EADDRINUSE, EACCES), which it throws as it came. A
73
+ * server that can serve without its byte-stream listener (a live ingest beside its HTTP host) falls back on this alone. */
74
+ export class StreamListenerUnavailable extends Error {
75
+ }
76
+ /** A stream connection's backlog (whole messages waiting for the twin's work) past which its socket is paused. */
77
+ const STREAM_HIGH_WATER = 4 * 1024 * 1024;
71
78
  /** A raw-TCP twin's listener (the seam's byte-stream half): its `create<Name>TwinStream` served on a TCP port, each
72
79
  * socket one connection, on loopback unless the caller names a hostname. Runtime-neutral, as `serveHttp` is: Node's
73
- * `net` reached at call time, never imported. */
80
+ * `net` reached at call time, never imported. A stream that declares `maxConnections` (a live ingest) is held to it. */
74
81
  export async function serveStream(options) {
75
- const net = builtin('node:net');
82
+ const get = process.getBuiltinModule;
83
+ const net = typeof get === 'function' ? get('node:net') : undefined;
84
+ if (!net || typeof net.createServer !== 'function')
85
+ throw new StreamListenerUnavailable('this runtime has no TCP listener (node:net)');
86
+ const cap = options.maxConnections ?? options.stream.maxConnections;
87
+ let open = 0;
76
88
  const server = net.createServer((socket) => {
89
+ // past its cap a connection is closed before the stream sees it, so connections that never get anywhere cannot pile up
90
+ if (cap !== undefined && open >= cap) {
91
+ socket.destroy();
92
+ return;
93
+ }
94
+ open += 1;
95
+ socket.once('close', () => { open -= 1; });
77
96
  const conn = options.stream({ write: (bytes) => { socket.write(bytes); }, end: () => { socket.end(); } }, socket.remoteAddress ?? '127.0.0.1');
78
- socket.on('data', (chunk) => conn.data(new Uint8Array(chunk)));
97
+ // a connection that says what waits (`backlog`: whole messages its work has not reached) is read no further past the
98
+ // high-water mark until that work has settled, so a peer faster than the twin (a live ingest pushed faster than real
99
+ // time) waits in TCP, not here. It is read again once settled, whatever else it holds: only more bytes can finish an
100
+ // unfinished message, and settling resolves once, so nothing re-arms in a loop
101
+ let paused = false;
102
+ const resume = () => { paused = false; if (!socket.destroyed)
103
+ socket.resume(); };
104
+ socket.on('data', (chunk) => {
105
+ conn.data(new Uint8Array(chunk));
106
+ if (!paused && conn.backlog && conn.backlog() > STREAM_HIGH_WATER) {
107
+ paused = true;
108
+ socket.pause();
109
+ void conn.settled().then(resume, resume);
110
+ }
111
+ });
79
112
  socket.on('close', () => conn.close());
80
113
  socket.on('error', () => conn.close());
81
114
  });
@@ -32,7 +32,9 @@ export declare function twinVendorUrl(request: Request, vendorUrl: string): stri
32
32
  * it over whatever carries the bytes — a local listener's socket, or a WebSocket to a hosted World.
33
33
  * The pack owns framing and protocol; the host owns the transport. `open` greets through the sink;
34
34
  * `data` hands the connection bytes in arrival order; `settled` resolves when every byte handed so
35
- * far has been answered; `close` is the peer going away. */
35
+ * far has been answered; `close` is the peer going away. A connection whose work can fall behind its bytes (a live
36
+ * ingest writing segments) says how many bytes wait in `backlog`, and a listener that can pause its transport does so
37
+ * past a high-water mark until `settled` (serve-http.ts, `serveStream`). */
36
38
  export type TwinStreamSink = {
37
39
  write(bytes: Uint8Array): void;
38
40
  end(): void;
@@ -41,6 +43,7 @@ export type TwinStreamConnection = {
41
43
  data(chunk: Uint8Array): void;
42
44
  settled(): Promise<void>;
43
45
  close(): void;
46
+ backlog?(): number;
44
47
  };
45
48
  export type TwinStream = (sink: TwinStreamSink, peer: string) => TwinStreamConnection;
46
49
  /** The request scopes a pack wrapped in `withRequestScopes` enforces, advertised on its `GET /twin` as
package/inject.cjs CHANGED
@@ -1731,6 +1731,19 @@ function installWorldTime() {
1731
1731
  function installMiniflareOutbound() {
1732
1732
  if (Module.__volterMiniflareOutboundInstalled) return;
1733
1733
  Module.__volterMiniflareOutboundInstalled = true;
1734
+ // Miniflare's Request is not necessarily an instance of the receiving fetch's Request. Pass a URL and explicitly
1735
+ // read its getters (spreading the Request loses them); neither Node nor another undici version must stringify it.
1736
+ const nodeFetch = (fetcher, request) => {
1737
+ const init = { method: request.method, headers: Array.from(request.headers) };
1738
+ for (const key of ['signal', 'redirect', 'cache', 'credentials', 'integrity', 'keepalive', 'mode', 'referrer', 'referrerPolicy']) {
1739
+ if (request[key] !== undefined) init[key] = request[key];
1740
+ }
1741
+ if (request.body != null) {
1742
+ init.body = request.body;
1743
+ init.duplex = 'half';
1744
+ }
1745
+ return fetcher(request.url, init);
1746
+ };
1734
1747
  // A host this process maps to a twin goes through the patched fetch. Any other goes where the World routes it: through
1735
1748
  // its proxy (HTTPS_PROXY, trusting the World's CA), which Node's own fetch does not read, so through miniflare's undici
1736
1749
  // with the environment's proxy agent; loopback stays direct.
@@ -1740,17 +1753,16 @@ function installMiniflareOutbound() {
1740
1753
  if (process.env.HTTPS_PROXY || process.env.HTTP_PROXY) {
1741
1754
  const undici = Module.createRequire(miniflarePath)('undici');
1742
1755
  const dispatcher = new undici.EnvHttpProxyAgent({ noProxy: localGuestProxy() ? '' : process.env.NO_PROXY || process.env.no_proxy || 'localhost,127.0.0.1,::1' });
1743
- proxied = (request) => undici.fetch(request, { dispatcher });
1756
+ proxied = (url, init) => undici.fetch(url, { ...init, dispatcher });
1744
1757
  }
1745
1758
  } catch { proxied = null; }
1746
1759
  const mapped = (request) => { try { const url = new URL(request.url); return Boolean(resolveTwin(url.hostname, MAP, url.pathname)); } catch { return false; } };
1747
- // The Request itself is handed on: its method, headers and body are getters, which a copy of it as `init` loses.
1748
1760
  return (request) => {
1749
1761
  // Node fetch cannot return an upgraded connection. Miniflare's fetch couples the socket to its response;
1750
1762
  // its ws client uses Node's request/socket paths, so the same injector routing and refusals still apply.
1751
1763
  if (request.method === 'GET' && request.headers.get('upgrade')?.toLowerCase() === 'websocket'
1752
1764
  && typeof upgradeFetch === 'function') return upgradeFetch(request, {});
1753
- return !proxied || (installed && mapped(request)) ? globalThis.fetch(request) : proxied(request);
1765
+ return nodeFetch(!proxied || (installed && mapped(request)) ? globalThis.fetch : proxied, request);
1754
1766
  };
1755
1767
  };
1756
1768
  let outbound = null;
@@ -1777,7 +1789,7 @@ function installMiniflareOutbound() {
1777
1789
  if (!outbound) {
1778
1790
  let at = null;
1779
1791
  try { at = Module._resolveFilename(request, parent); } catch { at = null; }
1780
- outbound = at ? outboundFor(at, exports.fetch) : (request) => globalThis.fetch(request);
1792
+ outbound = outboundFor(at, exports.fetch);
1781
1793
  }
1782
1794
  const Original = exports.Miniflare;
1783
1795
  class Miniflare extends Original {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-core",
3
- "version": "3.0.150",
3
+ "version": "3.0.152",
4
4
  "description": "The kernel of Volter World: one log per twin, branches as pointers, checkpoints, the fold that keeps a twin current, the head that performs a write against the vendor, references, and the git plane. A twin package builds on it; the runtime serves it.",
5
5
  "keywords": [
6
6
  "twin",
@@ -124,6 +124,10 @@
124
124
  "types": "./dist/src/mysql/index.d.ts",
125
125
  "default": "./dist/src/mysql/index.js"
126
126
  },
127
+ "./live": {
128
+ "types": "./dist/src/live/index.d.ts",
129
+ "default": "./dist/src/live/index.js"
130
+ },
127
131
  "./clickhouse": {
128
132
  "types": "./dist/src/clickhouse/index.d.ts",
129
133
  "default": "./dist/src/clickhouse/index.js"
@@ -16,6 +16,8 @@ import { GitRefs } from './git/refs.ts';
16
16
  import { worldNow } from './world-clock.ts';
17
17
  import { worldEnvValue } from './world-env.ts';
18
18
  import { openSessions, socketWrite, type SocketDecl, type SocketSession } from './sockets.ts';
19
+ import { liveAccess } from './live/access.ts';
20
+ import type { LiveAccess, LiveDecl } from './live/types.ts';
19
21
  import { awaitWrite, awaitCondition, heldReadsWrite } from './held-reads.ts';
20
22
  import { MACHINE_POOL, poolFor, guardedMachinePool, type MachinePool, type MachineHttpReply, type LightPoolOptions } from './machines.ts';
21
23
  import { askApplication, deliverEvents, type ApplicationAnswer, type ApplicationStandIn, type EventRender, type EventScheme, type EventsDecl, type EventValues } from './events.ts';
@@ -520,6 +522,10 @@ export type DerivedManifest = {
520
522
  /** The persistent wires the vendor's clients hold open (sockets.ts: Discord's Gateway): each served at its path by
521
523
  * the pack's `semantics/sockets.ts`, which the kernel offers every write after its webhooks. */
522
524
  sockets?: ReadonlyArray<SocketDecl>;
525
+ /** The broadcasts the vendor takes over RTMP (architecture, "Other wires": live media): each ingest's id, which names
526
+ * its engine in the pack's `semantics/live.ts`, and the RTMP app it answers. The pack's fetch carries the ingest
527
+ * (`fetch.ingest`), which its server serves on a side port. */
528
+ live?: ReadonlyArray<LiveDecl>;
523
529
  /** The vendor speaks gRPC (architecture, "Other wires": gRPC): its server answers HTTP/2 and HTTP/1 on one port, each
524
530
  * rpc (by its proto unit's schema, `src/generated/proto.gen.json`) through the pack's own fetch. */
525
531
  grpc?: true;
@@ -1773,6 +1779,9 @@ export type SemanticsContext = {
1773
1779
  issue(resource: string): Promise<string>;
1774
1780
  /** The vendor's open sessions of one of its sockets in this World (a request answered by a session: sockets.ts). */
1775
1781
  sockets(id: string): SocketSession[];
1782
+ /** The World's recordings of what was broadcast to this twin (live media, live/access.ts): a recording's facts, and its
1783
+ * playlists, init and segments answered as the vendor's media host answers them. */
1784
+ live: LiveAccess;
1776
1785
  /** Hold this read until the World writes (held-reads.ts: HTTP long polling): true when a write of one of the named
1777
1786
  * resources lands in this World, false when `ms` pass or the client hangs up; at once false in a read-only World. */
1778
1787
  awaitWrite(resources: ReadonlyArray<string>, ms: number): Promise<boolean>;
@@ -2139,6 +2148,7 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
2139
2148
  ...(options.database === undefined ? {} : { database: options.database }),
2140
2149
  ...(options.transaction === undefined ? {} : { transaction: options.transaction }),
2141
2150
  }),
2151
+ live: liveAccess({ rows: (type) => resourcesOfType(m.service, type, root) as unknown as Array<Record<string, unknown>>, blob: (key) => readResourceBlob(m.service, key, root) }, call.request),
2142
2152
  blobs: {
2143
2153
  put: (key, bytes) => writeResourceBlob(m.service, key, bytes, root),
2144
2154
  get: (key) => readResourceBlob(m.service, key, root),
package/src/derived.ts CHANGED
@@ -7,6 +7,7 @@ import { parseExactJson } from './exact-json.ts';
7
7
  // as the `/twin` door). `owners` says, per operation, which of the two serves it: the count a pack's
8
8
  // move is measured by. Workerd-clean: no fs, no clock, nothing at import.
9
9
  import type { WebSocketUpgrade } from './serve-http.ts';
10
+ import type { TwinStream } from './twin-fetch.ts';
10
11
  import { runWithRequestTrace } from './trace-context.ts';
11
12
 
12
13
  /** The slice of a generated surface the dispatch reads. */
@@ -214,6 +215,9 @@ export type DerivedFetch = ((request: Request) => Promise<Response>) & {
214
215
  ownersByUnit?(): Record<string, Record<string, DerivedOwner>>;
215
216
  /** The vendor's sockets (sockets.ts), for the serve seam to upgrade to, when its manifest declares any. */
216
217
  upgrade?: WebSocketUpgrade;
218
+ /** The vendor's RTMP ingest (live/ingest.ts), for its server to serve on its side port (`serveStream`), when its
219
+ * manifest declares `live`. */
220
+ ingest?: TwinStream;
217
221
  maxRequestBodySize?: number;
218
222
  /** The workspace screens it draws, its lanes' too (`GET /twin`'s `screens`). */
219
223
  workspaces?(): Workspace[];