@volter/world-core 3.0.151 → 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.
- package/dist/src/derived-core.d.ts +8 -0
- package/dist/src/derived-core.js +2 -0
- package/dist/src/derived.d.ts +4 -0
- package/dist/src/index.d.ts +3 -1
- package/dist/src/index.js +2 -1
- package/dist/src/live/access.d.ts +22 -0
- package/dist/src/live/access.js +83 -0
- package/dist/src/live/amf0.d.ts +7 -0
- package/dist/src/live/amf0.js +122 -0
- package/dist/src/live/flv.d.ts +42 -0
- package/dist/src/live/flv.js +148 -0
- package/dist/src/live/fmp4.d.ts +25 -0
- package/dist/src/live/fmp4.js +96 -0
- package/dist/src/live/hls.d.ts +22 -0
- package/dist/src/live/hls.js +46 -0
- package/dist/src/live/index.d.ts +9 -0
- package/dist/src/live/index.js +8 -0
- package/dist/src/live/ingest.d.ts +25 -0
- package/dist/src/live/ingest.js +229 -0
- package/dist/src/live/rtmp.d.ts +35 -0
- package/dist/src/live/rtmp.js +456 -0
- package/dist/src/live/segmenter.d.ts +57 -0
- package/dist/src/live/segmenter.js +204 -0
- package/dist/src/live/types.d.ts +102 -0
- package/dist/src/live/types.js +5 -0
- package/dist/src/pack-fetch.d.ts +4 -0
- package/dist/src/pack-fetch.js +24 -2
- package/dist/src/packRegistry.d.ts +1 -0
- package/dist/src/serve-http.d.ts +7 -1
- package/dist/src/serve-http.js +36 -3
- package/dist/src/twin-fetch.d.ts +4 -1
- package/package.json +5 -1
- package/src/derived-core.ts +10 -0
- package/src/derived.ts +4 -0
- package/src/index.ts +4 -1
- package/src/live/access.ts +89 -0
- package/src/live/amf0.ts +80 -0
- package/src/live/flv.ts +145 -0
- package/src/live/fmp4.ts +102 -0
- package/src/live/hls.ts +59 -0
- package/src/live/index.ts +13 -0
- package/src/live/ingest.ts +210 -0
- package/src/live/rtmp.ts +375 -0
- package/src/live/segmenter.ts +218 -0
- package/src/live/types.ts +105 -0
- package/src/pack-fetch.ts +28 -2
- package/src/packRegistry.ts +1 -1
- package/src/serve-http.ts +29 -4
- 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 {};
|
package/dist/src/pack-fetch.d.ts
CHANGED
|
@@ -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<{
|
package/dist/src/pack-fetch.js
CHANGED
|
@@ -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
|
}
|
package/dist/src/serve-http.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/serve-http.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
});
|
package/dist/src/twin-fetch.d.ts
CHANGED
|
@@ -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/world-core",
|
|
3
|
-
"version": "3.0.
|
|
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"
|
package/src/derived-core.ts
CHANGED
|
@@ -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[];
|
package/src/index.ts
CHANGED
|
@@ -29,6 +29,8 @@ export { verifyEvent } from './events.ts';
|
|
|
29
29
|
export { deriveStateSystem, deriveVendorStateSystem } from './derived-real.ts';
|
|
30
30
|
export type { IngestDecl } from './derived-core.ts';
|
|
31
31
|
export type { SocketDecl, SocketEngine, SocketSession } from './sockets.ts';
|
|
32
|
+
// live media (architecture, "Other wires": live media): an ingest's declaration, its dialect and what a screen reads
|
|
33
|
+
export type { LiveAccess, LiveAdmission, LiveAudio, LiveDecl, LiveEngine, LivePublish, LiveRecording, LiveRefusal, LiveServeOptions, LiveVideo } from './live/types.ts';
|
|
32
34
|
export { machinePool, isMachineCapabilityGap, type ExecSpec, type MachinePool, type MachineProcess, type MachineRun, type MachineSpec, type MachineStatus, type MachineFiles, type MachineFileStat } from './machines.ts';
|
|
33
35
|
export type { CorsDecl } from './cors.ts';
|
|
34
36
|
|
|
@@ -51,7 +53,7 @@ export { VendorUnreachableError } from './vendor-call.ts';
|
|
|
51
53
|
export { isReadOnlyRequest, READ_ONLY_REQUEST_HEADER } from './request-scope.ts';
|
|
52
54
|
|
|
53
55
|
// ── the serve factory ──────────────────────────────────────────────────────────────────────────
|
|
54
|
-
export { serveHttp, serveStream, type HttpHandler, type HttpServer, type ServeHttpOptions } from './serve-http.ts';
|
|
56
|
+
export { serveHttp, serveStream, StreamListenerUnavailable, type HttpHandler, type HttpServer, type ServeHttpOptions } from './serve-http.ts';
|
|
55
57
|
export type { TwinStream, TwinStreamConnection, TwinStreamSink } from './twin-fetch.ts';
|
|
56
58
|
export { privateTransportKey } from './private-transport.ts';
|
|
57
59
|
// where a page links a hostname its twin answers, inside the World that shows it
|
|
@@ -88,6 +90,7 @@ export { placeholderPng } from './placeholder-image.ts';
|
|
|
88
90
|
export { presignSigV4, signSigV4, verifySigV4, type SigV4Verdict } from './sigv4.ts';
|
|
89
91
|
export * as redis from './redis/index.ts';
|
|
90
92
|
export * as mysql from './mysql/index.ts';
|
|
93
|
+
export * as live from './live/index.ts';
|
|
91
94
|
export * as git from './git/index.ts';
|
|
92
95
|
|
|
93
96
|
// ── the scenario grammar (architecture, "Behaviour") ───────────────────────────────────────────
|