@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.
- 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/events.d.ts +4 -4
- package/dist/src/events.js +39 -15
- 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/events.ts +40 -16
- 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,210 @@
|
|
|
1
|
+
// THE INGEST (architecture, "Other wires": live media): an RTMP publisher's connection as the kernel's byte stream
|
|
2
|
+
// (`TwinStream`), its publish ruled by the pack's dialect (`semantics/live.ts`), its media cut into segments whose bytes
|
|
3
|
+
// go to the pack's resource blobs and whose facts go to the tree as bookkeeping (`_live`, `_live_segment`), and the
|
|
4
|
+
// dialect told when the publish starts playing and when it ends. What a connection is (the chunk stream, the encoder's
|
|
5
|
+
// configuration, the segment being cut) stays on the connection. The World's own writes and blobs are handed in
|
|
6
|
+
// (`LiveWorld`), so this module opens no store. Nothing here runs at module scope beyond the empty registry.
|
|
7
|
+
import type { TwinStream } from '../twin-fetch.ts';
|
|
8
|
+
import { initBlob, LIVE, LIVE_SEGMENT, recordingOf, segmentBlob, segmentsOf, type LiveReads } from './access.ts';
|
|
9
|
+
import { readAudioTag, readVideoTag } from './flv.ts';
|
|
10
|
+
import { RtmpRefusal, rtmpSession } from './rtmp.ts';
|
|
11
|
+
import { type Cut, LiveOverrun, Segmenter } from './segmenter.ts';
|
|
12
|
+
import type { LiveAdmission, LiveDecl, LiveEngine, LivePublish, LiveRecording } from './types.ts';
|
|
13
|
+
|
|
14
|
+
/** What the ingest reads and writes of the World, bound by the pack's fetch to its service and root. */
|
|
15
|
+
export type LiveWorld = LiveReads & {
|
|
16
|
+
/** a bookkeeping subject written (or, with `deleted: true`, deleted) at the World's time */
|
|
17
|
+
record(type: string, id: string, fields: Record<string, unknown>, operation: string): Promise<void>;
|
|
18
|
+
putBlob(key: string, bytes: Uint8Array): Promise<void>;
|
|
19
|
+
removeBlob(key: string): Promise<void>;
|
|
20
|
+
now(): string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type LiveIngestOptions<C> = {
|
|
24
|
+
/** the World this ingest's recordings are kept in (its service and root): one publisher per stream within it */
|
|
25
|
+
world: string;
|
|
26
|
+
decls: ReadonlyArray<LiveDecl>;
|
|
27
|
+
engines: Record<string, LiveEngine<C>>;
|
|
28
|
+
readOnly: boolean;
|
|
29
|
+
/** the handler contract's context a hook runs over, opened at the moment it runs */
|
|
30
|
+
open(decl: LiveDecl, ask: LivePublish): Promise<C>;
|
|
31
|
+
/** runs a hook and the writes it causes as the vendor's own move, one correlation */
|
|
32
|
+
run<T>(fn: () => Promise<T>): Promise<T>;
|
|
33
|
+
store: LiveWorld;
|
|
34
|
+
log?(line: string): void;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const STREAM_ID = /^[A-Za-z0-9._-]{1,128}$/;
|
|
38
|
+
/** Connections one ingest's listener takes at once: publishers and the connections still on their way to publishing. */
|
|
39
|
+
const MAX_CONNECTIONS = 32;
|
|
40
|
+
/** The streams being published now, per World: a second publisher to one is refused, as a vendor's ingest refuses it. */
|
|
41
|
+
const publishing = new Map<string, Set<string>>();
|
|
42
|
+
|
|
43
|
+
export function liveIngest<C>(options: LiveIngestOptions<C>): TwinStream {
|
|
44
|
+
for (const d of options.decls) if (!options.engines[d.id]) throw new Error(`the manifest declares the ingest ${d.id}, and semantics/live.ts exports no ${d.id}`);
|
|
45
|
+
const log = options.log ?? ((line: string) => console.error(`live: ${line}`));
|
|
46
|
+
const store = options.store;
|
|
47
|
+
const active = publishing.get(options.world) ?? publishing.set(options.world, new Set()).get(options.world)!;
|
|
48
|
+
|
|
49
|
+
// the listener's cap (serveStream reads it): a World's publishers are its encoders, a few at once
|
|
50
|
+
return Object.assign((sink: Parameters<TwinStream>[0], peer: string): ReturnType<TwinStream> => {
|
|
51
|
+
let decl: LiveDecl | undefined;
|
|
52
|
+
let ask: LivePublish | undefined;
|
|
53
|
+
let stream: string | undefined;
|
|
54
|
+
let admitted: Extract<LiveAdmission, { stream: string }> | undefined;
|
|
55
|
+
let segmenter: Segmenter | undefined;
|
|
56
|
+
let initIndex = -1;
|
|
57
|
+
let priorInit: number | undefined;
|
|
58
|
+
let started = false;
|
|
59
|
+
let metadata: Record<string, unknown> | undefined;
|
|
60
|
+
let refusedCodec: string | undefined;
|
|
61
|
+
|
|
62
|
+
const recording = (): LiveRecording => {
|
|
63
|
+
const row = store.rows(LIVE).find((r) => r.id === stream)!;
|
|
64
|
+
return recordingOf(row, segmentsOf(store, stream!));
|
|
65
|
+
};
|
|
66
|
+
const hook = async (name: 'started' | 'ended'): Promise<void> => {
|
|
67
|
+
const engine = options.engines[decl!.id]!;
|
|
68
|
+
const fn = engine[name];
|
|
69
|
+
if (!fn) return;
|
|
70
|
+
try { await options.run(async () => fn.call(engine, await options.open(decl!, ask!), recording())); }
|
|
71
|
+
catch (error) { log(`${decl!.id}'s ${name} for ${stream} was not written: ${String((error as Error)?.message ?? error)}`); }
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** A closed segment kept: its init first when it opens one, its bytes, its subject; then what `retain` lets go. */
|
|
75
|
+
const keep = async (cut: Cut): Promise<void> => {
|
|
76
|
+
if (!stream || !admitted) return;
|
|
77
|
+
if (cut.init) {
|
|
78
|
+
// a publisher that reconnects with the encoder it had plays on under the recording's last init, its timeline
|
|
79
|
+
// carried on, so no player meets a discontinuity; a changed configuration opens the next init
|
|
80
|
+
const prior = initIndex < 0 && priorInit !== undefined ? await store.blob(initBlob(stream, priorInit)) : null;
|
|
81
|
+
if (prior && sameBytes(prior, cut.init.bytes)) initIndex = priorInit!;
|
|
82
|
+
else {
|
|
83
|
+
initIndex = initIndex < 0 ? (priorInit ?? -1) + 1 : initIndex + 1;
|
|
84
|
+
await store.putBlob(initBlob(stream, initIndex), cut.init.bytes);
|
|
85
|
+
}
|
|
86
|
+
await options.run(() => store.record(LIVE, stream!, {
|
|
87
|
+
...(cut.init!.video ? { video: cut.init!.video } : {}),
|
|
88
|
+
...(cut.init!.audio ? { audio: cut.init!.audio } : {}),
|
|
89
|
+
...(metadata ? { metadata } : {}),
|
|
90
|
+
}, 'live.configure'));
|
|
91
|
+
}
|
|
92
|
+
const blob = segmentBlob(stream, cut.sequence);
|
|
93
|
+
await store.putBlob(blob, cut.bytes);
|
|
94
|
+
await options.run(() => store.record(LIVE_SEGMENT, `${stream}:${cut.sequence}`, {
|
|
95
|
+
stream, seq: cut.sequence, init: initIndex, start: round(cut.start), duration: round(cut.duration), size: cut.bytes.length, blob,
|
|
96
|
+
}, 'live.segment'));
|
|
97
|
+
if (admitted.retain !== undefined && admitted.retain > 0) {
|
|
98
|
+
for (const old of segmentsOf(store, stream).slice(0, -admitted.retain)) {
|
|
99
|
+
await store.removeBlob(old.blob);
|
|
100
|
+
await options.run(() => store.record(LIVE_SEGMENT, `${stream}:${old.seq}`, { deleted: true }, 'live.segment.expire'));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (!started) { started = true; await hook('started'); }
|
|
104
|
+
};
|
|
105
|
+
const keepAll = async (cuts: Cut[]): Promise<void> => { for (const cut of cuts) await keep(cut); };
|
|
106
|
+
|
|
107
|
+
return rtmpSession(sink, {
|
|
108
|
+
connect(app) {
|
|
109
|
+
decl = options.decls.find((d) => d.app === app);
|
|
110
|
+
return decl ? undefined : `There is no application ${app || '(none)'} here.`;
|
|
111
|
+
},
|
|
112
|
+
async publish(asked) {
|
|
113
|
+
if (!decl) return { code: 'NetStream.Publish.BadName', description: 'No application was connected.' };
|
|
114
|
+
if (options.readOnly) return { code: 'NetStream.Publish.Denied', description: 'This World is read-only: it records nothing.' };
|
|
115
|
+
const [key = '', query = ''] = splitOnce(asked.name, '?');
|
|
116
|
+
ask = {
|
|
117
|
+
app: asked.app, key, query: Object.fromEntries(new URLSearchParams(query)), peer,
|
|
118
|
+
...(asked.tcUrl !== undefined ? { tcUrl: asked.tcUrl } : {}), ...(asked.flashVer !== undefined ? { flashVer: asked.flashVer } : {}), ...(asked.type !== undefined ? { type: asked.type } : {}),
|
|
119
|
+
};
|
|
120
|
+
const engine = options.engines[decl.id]!;
|
|
121
|
+
const ruling = await options.run(async () => engine.publish(await options.open(decl!, ask!), ask!));
|
|
122
|
+
if ('refused' in ruling) return { code: ruling.refused, description: ruling.description };
|
|
123
|
+
if (!STREAM_ID.test(ruling.stream)) throw new Error(`${decl.id}'s publish named the stream ${JSON.stringify(ruling.stream)}; a stream is 1-128 letters, digits, '.', '_' or '-'`);
|
|
124
|
+
if (active.has(ruling.stream)) return { code: 'NetStream.Publish.BadName', description: `The stream ${ruling.stream} is already being published.` };
|
|
125
|
+
const named = ruling.stream;
|
|
126
|
+
active.add(named);
|
|
127
|
+
try {
|
|
128
|
+
// a recording continued: its segments' numbers, its inits' and its timeline carry on from where it stood
|
|
129
|
+
const prior = store.rows(LIVE).find((r) => r.id === named && r.deleted !== true);
|
|
130
|
+
const kept = segmentsOf(store, named);
|
|
131
|
+
const last = kept[kept.length - 1];
|
|
132
|
+
const offsetMs = last ? Math.ceil((last.start + last.duration) * 1000) : 0;
|
|
133
|
+
const now = store.now();
|
|
134
|
+
await options.run(() => store.record(LIVE, named, {
|
|
135
|
+
app: ask!.app, key: ask!.key, state: 'publishing', startedAt: typeof prior?.startedAt === 'string' ? prior.startedAt : now, endedAt: null,
|
|
136
|
+
publishes: Number(prior?.publishes ?? 0) + 1, window: ruling.window ?? 6, segmentSeconds: ruling.segmentSeconds ?? 2, ...(ruling.retain !== undefined ? { retain: ruling.retain } : {}),
|
|
137
|
+
}, 'live.publish'));
|
|
138
|
+
priorInit = last?.init;
|
|
139
|
+
segmenter = new Segmenter({ targetMs: Math.max(0.5, ruling.segmentSeconds ?? 2) * 1000, offsetMs, firstSequence: last ? last.seq + 1 : 0 });
|
|
140
|
+
stream = named;
|
|
141
|
+
admitted = ruling;
|
|
142
|
+
} catch (error) {
|
|
143
|
+
// the publish was not recorded: the stream is free again, and the publisher refused (rtmp.ts)
|
|
144
|
+
active.delete(named);
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
return undefined;
|
|
148
|
+
},
|
|
149
|
+
metadata(meta) {
|
|
150
|
+
const flat = flatMetadata(meta as Record<string, unknown>);
|
|
151
|
+
metadata = { ...(metadata ?? {}), ...flat };
|
|
152
|
+
segmenter?.metadata(flat);
|
|
153
|
+
},
|
|
154
|
+
async video(timestamp, payload) {
|
|
155
|
+
if (!segmenter) return;
|
|
156
|
+
const tag = readVideoTag(payload);
|
|
157
|
+
if (tag.kind === 'unsupported') { if (refusedCodec !== tag.codec) log(`${stream}: ${tag.codec} video is not recorded (H.264 is)`); refusedCodec = tag.codec; return; }
|
|
158
|
+
const s = segmenter;
|
|
159
|
+
if (tag.kind === 'config') await keepAll(s.videoConfig(tag.config));
|
|
160
|
+
else if (tag.kind === 'frame') await keepAll(overrun(() => s.videoFrame(timestamp, tag.key, tag.cts, tag.data)));
|
|
161
|
+
},
|
|
162
|
+
async audio(timestamp, payload) {
|
|
163
|
+
if (!segmenter) return;
|
|
164
|
+
const tag = readAudioTag(payload);
|
|
165
|
+
if (tag.kind === 'unsupported') { if (refusedCodec !== tag.codec) log(`${stream}: ${tag.codec} audio is not recorded (AAC is)`); refusedCodec = tag.codec; return; }
|
|
166
|
+
const s = segmenter;
|
|
167
|
+
if (tag.kind === 'config') await keepAll(s.audioConfig(tag.config));
|
|
168
|
+
else await keepAll(overrun(() => s.audioFrame(timestamp, tag.data)));
|
|
169
|
+
},
|
|
170
|
+
async unpublish() {
|
|
171
|
+
if (!stream || !segmenter) return;
|
|
172
|
+
const s = segmenter;
|
|
173
|
+
segmenter = undefined;
|
|
174
|
+
// the recording always ends: what was cut is kept where it can be, and the end is written whatever the last
|
|
175
|
+
// segment did, so no playlist is left waiting on a publisher that is gone
|
|
176
|
+
try { await keepAll(s.flush()); } catch (error) { log(`${stream}: the last segment was not kept: ${String((error as Error)?.message ?? error)}`); }
|
|
177
|
+
try { await options.run(() => store.record(LIVE, stream!, { state: 'ended', endedAt: store.now() }, 'live.end')); }
|
|
178
|
+
catch (error) { log(`${stream}: its end was not written: ${String((error as Error)?.message ?? error)}`); }
|
|
179
|
+
finally { active.delete(stream); }
|
|
180
|
+
await hook('ended');
|
|
181
|
+
},
|
|
182
|
+
}, log);
|
|
183
|
+
}, { maxConnections: MAX_CONNECTIONS });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const round = (n: number): number => Math.round(n * 1000) / 1000;
|
|
187
|
+
/** What a segmenter refuses, as RTMP's refusal of the publish (rtmp.ts ends it and closes the connection). */
|
|
188
|
+
function overrun<T>(fn: () => T): T {
|
|
189
|
+
try { return fn(); } catch (error) { throw error instanceof LiveOverrun ? new RtmpRefusal('NetStream.Failed', error.message) : error; }
|
|
190
|
+
}
|
|
191
|
+
/** The publisher's metadata as the World keeps it: its flat values (`onMetaData` is one level: an encoder's name, sizes,
|
|
192
|
+
* rates), at most 64 of them, a string at most 256 characters; nested values are not kept, so nothing it sent (a
|
|
193
|
+
* reference back to itself among them) can make the recording's row unwritable. */
|
|
194
|
+
function flatMetadata(meta: Record<string, unknown>): Record<string, string | number | boolean> {
|
|
195
|
+
const out: Record<string, string | number | boolean> = {};
|
|
196
|
+
for (const [key, value] of Object.entries(meta).slice(0, 64)) {
|
|
197
|
+
if (typeof value === 'number' ? Number.isFinite(value) : typeof value === 'boolean') out[key.slice(0, 64)] = value as number | boolean;
|
|
198
|
+
else if (typeof value === 'string') out[key.slice(0, 64)] = value.slice(0, 256);
|
|
199
|
+
}
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
function sameBytes(a: Uint8Array, b: Uint8Array): boolean {
|
|
203
|
+
if (a.length !== b.length) return false;
|
|
204
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
function splitOnce(s: string, sep: string): [string, string] {
|
|
208
|
+
const at = s.indexOf(sep);
|
|
209
|
+
return at < 0 ? [s, ''] : [s.slice(0, at), s.slice(at + 1)];
|
|
210
|
+
}
|
package/src/live/rtmp.ts
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
// RTMP's server side for a publisher (Adobe's RTMP 1.0 specification: §5.2 the handshake, §5.3 the chunk stream, §5.4
|
|
2
|
+
// and §6.2 the control messages, §7.2 the commands), as OBS and ffmpeg publish: C0/C1/C2 answered with S0/S1/S2, the
|
|
3
|
+
// chunk stream reassembled into messages, the peer's chunk size and acknowledgement window honoured, and the commands a
|
|
4
|
+
// publisher sends (`connect`, `releaseStream`, `FCPublish`, `createStream`, `publish`, `FCUnpublish`, `deleteStream`)
|
|
5
|
+
// answered as a media server answers them. Media and data messages are handed on as they arrive; what a publish means is
|
|
6
|
+
// the caller's (`publish`). Every message is handled in arrival order, each after the last has settled.
|
|
7
|
+
//
|
|
8
|
+
// What a connection may hold is bounded (architecture, "Other wires": live media): a message is reassembled from the
|
|
9
|
+
// bytes that arrived, never allocated at the length its header declares; the bytes held in messages being reassembled,
|
|
10
|
+
// the chunk streams open and the whole messages waiting their turn each have a ceiling, far lower before a publish is
|
|
11
|
+
// admitted, and a connection past one is closed. `backlog()` says how much is waiting, so the listener pauses the socket
|
|
12
|
+
// while the World records (serve-http.ts, `serveStream`). Nothing here runs at module scope.
|
|
13
|
+
import type { TwinStreamConnection, TwinStreamSink } from '../twin-fetch.ts';
|
|
14
|
+
import { type Amf0Value, decodeAmf0, encodeAmf0 } from './amf0.ts';
|
|
15
|
+
|
|
16
|
+
/** What the session asks of its caller. */
|
|
17
|
+
export type RtmpPublisher = {
|
|
18
|
+
/** `connect`: the application the publisher names, and its command object; a string refuses it, with that reason */
|
|
19
|
+
connect(app: string, command: Record<string, Amf0Value>): string | undefined;
|
|
20
|
+
/** `publish`: whether the stream may be published, or RTMP's refusal (its code and description) */
|
|
21
|
+
publish(ask: { app: string; name: string; type?: string; tcUrl?: string; flashVer?: string }): Promise<{ code: string; description: string } | undefined>;
|
|
22
|
+
/** `@setDataFrame` / `onMetaData`, as published */
|
|
23
|
+
metadata(meta: Record<string, Amf0Value>): void | Promise<void>;
|
|
24
|
+
/** an FLV audio or video tag's body, at its RTMP timestamp (milliseconds); an `RtmpRefusal` thrown ends the publish */
|
|
25
|
+
audio(timestamp: number, payload: Uint8Array): void | Promise<void>;
|
|
26
|
+
video(timestamp: number, payload: Uint8Array): void | Promise<void>;
|
|
27
|
+
/** the publish ended (`FCUnpublish`, `closeStream`, `deleteStream`, the connection ended while publishing, or a
|
|
28
|
+
* refusal of what was published) */
|
|
29
|
+
unpublish(): void | Promise<void>;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** What a publisher's media can draw mid-publish: its `onStatus` error, then the publish ended and the connection closed. */
|
|
33
|
+
export class RtmpRefusal extends Error {
|
|
34
|
+
constructor(readonly code: string, description: string) { super(description); }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const HANDSHAKE = 1536;
|
|
38
|
+
/** The largest message: the chunk header's 24-bit length, the wire's own ceiling. The largest video frame measured fits
|
|
39
|
+
* under it: 11,781,334 bytes, a 2160p frame of noise at CRF 1 (ffmpeg 8.1.2, libx264 ultrafast, `ffmpeg -f lavfi -i
|
|
40
|
+
* "testsrc2=size=3840x2160:rate=30,noise=alls=30:allf=t+u" -frames:v 6 -c:v libx264 -preset ultrafast -crf 1 -f flv`, the
|
|
41
|
+
* largest `ffprobe -show_entries packet=size`), beside 320,672 bytes for 1080p60 at a Twitch-like 8 Mbit/s CBR and
|
|
42
|
+
* 2,964,434 for 1080p at CRF 1; measured on Windows 11, twin-world 5c01b42b, nothing else encoding. */
|
|
43
|
+
const MAX_MESSAGE = 0xffffff;
|
|
44
|
+
/** Before a publish is admitted only commands come, a few hundred bytes each (ffmpeg's and librtmp's `connect` object):
|
|
45
|
+
* a message, and what a connection holds in reassembly, stay small until the dialect has admitted the stream key. */
|
|
46
|
+
const MAX_MESSAGE_UNADMITTED = 64 * 1024;
|
|
47
|
+
const MAX_HELD_UNADMITTED = 256 * 1024;
|
|
48
|
+
/** Once publishing: one frame reassembling while audio interleaves on its own chunk stream. */
|
|
49
|
+
const MAX_HELD = MAX_MESSAGE + 1024 * 1024;
|
|
50
|
+
/** Chunk streams a connection may open: ffmpeg uses 2, 3, 4, 6 and 8 (libavformat/rtmppkt.h, `RTMPChannel`), librtmp 2
|
|
51
|
+
* to 5 and 8 (rtmp.c's `m_nChannel`). */
|
|
52
|
+
const MAX_CHUNK_STREAMS = 64;
|
|
53
|
+
/** Whole messages waiting their turn: the listener pauses past a few megabytes (serveStream); a transport that cannot
|
|
54
|
+
* pause (a bridged WebSocket) is closed past this. */
|
|
55
|
+
const MAX_QUEUED = 64 * 1024 * 1024;
|
|
56
|
+
/** A connection that has not published this long after it opened is closed: one that never gets as far as a stream
|
|
57
|
+
* key holds its place no longer than an encoder needs to connect and be admitted. */
|
|
58
|
+
const PUBLISH_DEADLINE_MS = 30_000;
|
|
59
|
+
const WINDOW = 5_000_000;
|
|
60
|
+
const OUT_CHUNK = 4096;
|
|
61
|
+
|
|
62
|
+
type ChunkState = { timestamp: number; tsField: number; extended: boolean; length: number; type: number; streamId: number; parts: Uint8Array[]; received: number };
|
|
63
|
+
type Message = { type: number; streamId: number; timestamp: number; payload: Uint8Array };
|
|
64
|
+
|
|
65
|
+
const u24 = (b: Uint8Array, at: number): number => (b[at]! << 16) | (b[at + 1]! << 8) | b[at + 2]!;
|
|
66
|
+
const u32 = (b: Uint8Array, at: number): number => ((b[at]! << 24) >>> 0) + ((b[at + 1]! << 16) | (b[at + 2]! << 8) | b[at + 3]!);
|
|
67
|
+
const u32le = (b: Uint8Array, at: number): number => (b[at]! | (b[at + 1]! << 8) | (b[at + 2]! << 16)) + ((b[at + 3]! << 24) >>> 0);
|
|
68
|
+
const be32 = (n: number): number[] => [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff];
|
|
69
|
+
|
|
70
|
+
function join(parts: Uint8Array[], length: number): Uint8Array {
|
|
71
|
+
if (parts.length === 1 && parts[0]!.length === length) return parts[0]!;
|
|
72
|
+
const out = new Uint8Array(length);
|
|
73
|
+
let at = 0;
|
|
74
|
+
for (const p of parts) { out.set(p, at); at += p.length; }
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** One publisher's connection: its bytes in, the server's answers out through the sink. */
|
|
79
|
+
export function rtmpSession(sink: TwinStreamSink, publisher: RtmpPublisher, log: (line: string) => void = () => {}): TwinStreamConnection & { backlog(): number } {
|
|
80
|
+
let pending: Uint8Array = new Uint8Array(0);
|
|
81
|
+
let phase: 'c0c1' | 'c2' | 'chunks' | 'closed' = 'c0c1';
|
|
82
|
+
let inChunk = 128;
|
|
83
|
+
let received = 0;
|
|
84
|
+
let acked = 0;
|
|
85
|
+
let peerWindow = 0;
|
|
86
|
+
let app = '';
|
|
87
|
+
let tcUrl: string | undefined;
|
|
88
|
+
let flashVer: string | undefined;
|
|
89
|
+
let nextStream = 1;
|
|
90
|
+
let publishing = false;
|
|
91
|
+
let publishStream = 0;
|
|
92
|
+
let ended = false;
|
|
93
|
+
const chunks = new Map<number, ChunkState>();
|
|
94
|
+
/** a chunk whose header was read and whose payload is still arriving */
|
|
95
|
+
let current: { st: ChunkState; left: number } | undefined;
|
|
96
|
+
/** bytes held in messages being reassembled, and in whole messages waiting their turn */
|
|
97
|
+
let held = 0;
|
|
98
|
+
let queued = 0;
|
|
99
|
+
let chain: Promise<void> = Promise.resolve();
|
|
100
|
+
const deadline: ReturnType<typeof setTimeout> | undefined = setTimeout(() => { if (!publishing && !ended) overrun(`it did not publish within ${PUBLISH_DEADLINE_MS / 1000} s`); }, PUBLISH_DEADLINE_MS);
|
|
101
|
+
(deadline as { unref?: () => void }).unref?.();
|
|
102
|
+
|
|
103
|
+
let outChunk = 128;
|
|
104
|
+
const write = (bytes: Uint8Array): void => { if (phase !== 'closed') sink.write(bytes); };
|
|
105
|
+
/** A message as chunks of the server's chunk size: a type-0 header, then type-3 continuations. */
|
|
106
|
+
const send = (csid: number, type: number, streamId: number, payload: Uint8Array, timestamp = 0): void => {
|
|
107
|
+
const head = [csid & 0x3f, ...be32(Math.min(timestamp, 0xffffff)).slice(1), ...be32(payload.length).slice(1), type, streamId & 0xff, (streamId >> 8) & 0xff, (streamId >> 16) & 0xff, (streamId >>> 24) & 0xff];
|
|
108
|
+
const parts: number[] = head;
|
|
109
|
+
for (let at = 0; at < payload.length; at += outChunk) {
|
|
110
|
+
if (at > 0) parts.push(0xc0 | (csid & 0x3f));
|
|
111
|
+
for (const b of payload.subarray(at, at + outChunk)) parts.push(b);
|
|
112
|
+
}
|
|
113
|
+
write(Uint8Array.from(parts));
|
|
114
|
+
};
|
|
115
|
+
const control = (type: number, body: number[]): void => send(2, type, 0, Uint8Array.from(body));
|
|
116
|
+
const command = (csid: number, streamId: number, ...values: Amf0Value[]): void => send(csid, 20, streamId, encodeAmf0(...values));
|
|
117
|
+
const status = (streamId: number, level: 'status' | 'error', code: string, description: string, details?: string): void =>
|
|
118
|
+
command(5, streamId, 'onStatus', 0, null, { level, code, description, ...(details !== undefined ? { details } : {}) });
|
|
119
|
+
const close = (): void => {
|
|
120
|
+
if (phase === 'closed') return;
|
|
121
|
+
phase = 'closed';
|
|
122
|
+
clearTimeout(deadline);
|
|
123
|
+
pending = new Uint8Array(0);
|
|
124
|
+
chunks.clear();
|
|
125
|
+
current = undefined;
|
|
126
|
+
held = 0;
|
|
127
|
+
try { sink.end(); } catch { /* the peer is gone */ }
|
|
128
|
+
};
|
|
129
|
+
/** A connection past a ceiling: said once, and closed. */
|
|
130
|
+
const overrun = (what: string): void => { log(`closing a connection: ${what}`); close(); };
|
|
131
|
+
const stop = async (): Promise<void> => {
|
|
132
|
+
if (!publishing || ended) return;
|
|
133
|
+
ended = true;
|
|
134
|
+
publishing = false;
|
|
135
|
+
await publisher.unpublish();
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const onCommand = async (message: Message, values: Amf0Value[]): Promise<void> => {
|
|
139
|
+
const [name, txnValue, object, ...args] = values;
|
|
140
|
+
const txn = typeof txnValue === 'number' ? txnValue : 0;
|
|
141
|
+
switch (name) {
|
|
142
|
+
case 'connect': {
|
|
143
|
+
const cmd = (object && typeof object === 'object' && !Array.isArray(object) && !(object instanceof Date) ? object : {}) as Record<string, Amf0Value>;
|
|
144
|
+
app = String(cmd.app ?? '').replace(/^\/+|\/+$/g, '');
|
|
145
|
+
tcUrl = typeof cmd.tcUrl === 'string' ? cmd.tcUrl : undefined;
|
|
146
|
+
flashVer = typeof cmd.flashVer === 'string' ? cmd.flashVer : undefined;
|
|
147
|
+
const refused = publisher.connect(app, cmd);
|
|
148
|
+
if (refused !== undefined) {
|
|
149
|
+
command(3, 0, '_error', txn, null, { level: 'error', code: 'NetConnection.Connect.Rejected', description: refused });
|
|
150
|
+
close();
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
control(5, be32(WINDOW));
|
|
154
|
+
control(6, [...be32(WINDOW), 2]);
|
|
155
|
+
control(1, be32(OUT_CHUNK));
|
|
156
|
+
outChunk = OUT_CHUNK;
|
|
157
|
+
command(3, 0, '_result', txn, { fmsVer: 'FMS/3,0,1,123', capabilities: 31 }, { level: 'status', code: 'NetConnection.Connect.Success', description: 'Connection succeeded.', objectEncoding: 0 });
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
case 'releaseStream':
|
|
161
|
+
case 'FCPublish':
|
|
162
|
+
case 'getStreamLength':
|
|
163
|
+
case '_checkbw':
|
|
164
|
+
if (txn > 0) command(3, 0, '_result', txn, null, undefined);
|
|
165
|
+
return;
|
|
166
|
+
case 'createStream':
|
|
167
|
+
command(3, 0, '_result', txn, null, nextStream++);
|
|
168
|
+
return;
|
|
169
|
+
case 'publish': {
|
|
170
|
+
const stream = message.streamId;
|
|
171
|
+
const named = typeof args[0] === 'string' ? args[0] : '';
|
|
172
|
+
if (publishing) { status(stream, 'error', 'NetStream.Publish.BadName', 'This connection is already publishing.', named); return; }
|
|
173
|
+
const type = typeof args[1] === 'string' ? args[1] : undefined;
|
|
174
|
+
let refusal: { code: string; description: string } | undefined;
|
|
175
|
+
try { refusal = await publisher.publish({ app, name: named, ...(type !== undefined ? { type } : {}), ...(tcUrl !== undefined ? { tcUrl } : {}), ...(flashVer !== undefined ? { flashVer } : {}) }); }
|
|
176
|
+
catch (error) { log(`publish failed: ${String((error as Error)?.message ?? error)}`); refusal = { code: 'NetStream.Publish.Denied', description: 'The server could not take the stream.' }; }
|
|
177
|
+
if (refusal) { status(stream, 'error', refusal.code, refusal.description, named); close(); return; }
|
|
178
|
+
publishing = true;
|
|
179
|
+
publishStream = stream;
|
|
180
|
+
// user control StreamBegin (event 0) for the publish's stream, then its start
|
|
181
|
+
control(4, [0, 0, ...be32(stream)]);
|
|
182
|
+
status(stream, 'status', 'NetStream.Publish.Start', `${named.split('?')[0]} is now published.`, named.split('?')[0]);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
case 'FCUnpublish':
|
|
186
|
+
case 'closeStream':
|
|
187
|
+
case 'deleteStream':
|
|
188
|
+
await stop();
|
|
189
|
+
return;
|
|
190
|
+
case 'play':
|
|
191
|
+
status(message.streamId, 'error', 'NetStream.Play.Failed', 'This server takes published streams; it plays none over RTMP.');
|
|
192
|
+
close();
|
|
193
|
+
return;
|
|
194
|
+
default:
|
|
195
|
+
if (txn > 0) command(3, 0, '_error', txn, null, { level: 'error', code: 'NetConnection.Call.Failed', description: `${String(name)} is not a command this server answers.` });
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
/** What was published, handed on: a refusal of it ends the publish (its `onStatus` error first) and the connection. */
|
|
200
|
+
const media = async (hand: () => void | Promise<void>): Promise<void> => {
|
|
201
|
+
try { await hand(); } catch (error) {
|
|
202
|
+
if (!(error instanceof RtmpRefusal)) throw error;
|
|
203
|
+
status(publishStream, 'error', error.code, error.message);
|
|
204
|
+
await stop();
|
|
205
|
+
close();
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const onMessage = async (message: Message): Promise<void> => {
|
|
210
|
+
const p = message.payload;
|
|
211
|
+
switch (message.type) {
|
|
212
|
+
case 3: case 6: return;
|
|
213
|
+
case 4: {
|
|
214
|
+
const event = (p[0]! << 8) | p[1]!;
|
|
215
|
+
// a ping request is answered with the same timestamp (event 7, PingResponse)
|
|
216
|
+
if (event === 6 && p.length >= 6) control(4, [0, 7, ...Array.from(p.subarray(2, 6))]);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
case 5: peerWindow = u32(p, 0); return;
|
|
220
|
+
case 8: if (publishing) await media(() => publisher.audio(message.timestamp, p)); return;
|
|
221
|
+
case 9: if (publishing) await media(() => publisher.video(message.timestamp, p)); return;
|
|
222
|
+
case 15: case 18: {
|
|
223
|
+
const values = decodeAmf0(message.type === 15 ? p.subarray(1) : p);
|
|
224
|
+
const meta = values[0] === '@setDataFrame' ? values[2] : values[0] === 'onMetaData' ? values[1] : undefined;
|
|
225
|
+
if (publishing && meta && typeof meta === 'object' && !Array.isArray(meta) && !(meta instanceof Date)) await media(() => publisher.metadata(meta));
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
case 17: case 20: await onCommand(message, decodeAmf0(message.type === 17 ? p.subarray(1) : p)); return;
|
|
229
|
+
default: return;
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
/** A chunk's bytes taken into its message, within what the connection may hold; false when past it (closed). */
|
|
234
|
+
const absorb = (st: ChunkState, bytes: Uint8Array): boolean => {
|
|
235
|
+
if (bytes.length === 0) return true;
|
|
236
|
+
const ceiling = publishing ? MAX_HELD : MAX_HELD_UNADMITTED;
|
|
237
|
+
if (held + bytes.length > ceiling) { overrun(`it holds more than ${ceiling} bytes of unfinished messages`); return false; }
|
|
238
|
+
st.parts.push(bytes.slice());
|
|
239
|
+
st.received += bytes.length;
|
|
240
|
+
held += bytes.length;
|
|
241
|
+
return true;
|
|
242
|
+
};
|
|
243
|
+
/** A chunk ended: its message, when whole, handled; the chunk size and an abort take effect at once, before the next
|
|
244
|
+
* chunk is read (RTMP 1.0 §5.4.1, §5.4.2). */
|
|
245
|
+
const finish = (st: ChunkState): void => {
|
|
246
|
+
if (st.received < st.length) return;
|
|
247
|
+
const payload = join(st.parts, st.length);
|
|
248
|
+
held -= st.received;
|
|
249
|
+
st.parts = [];
|
|
250
|
+
st.received = 0;
|
|
251
|
+
if (st.type === 1) { inChunk = Math.max(1, u32(payload, 0) & 0x7fffffff); return; }
|
|
252
|
+
if (st.type === 2) {
|
|
253
|
+
const aborted = chunks.get(u32(payload, 0));
|
|
254
|
+
if (aborted) { held -= aborted.received; aborted.parts = []; aborted.received = 0; if (current?.st === aborted) current = undefined; }
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const message: Message = { type: st.type, streamId: st.streamId, timestamp: st.timestamp, payload };
|
|
258
|
+
queued += payload.length;
|
|
259
|
+
if (queued > MAX_QUEUED) { overrun(`more than ${MAX_QUEUED} bytes of messages wait to be recorded`); return; }
|
|
260
|
+
chain = chain.then(() => (phase === 'closed' && message.type !== 20 ? undefined : onMessage(message))).catch((error) => {
|
|
261
|
+
log(`a message failed: ${String((error as Error)?.message ?? error)}`);
|
|
262
|
+
close();
|
|
263
|
+
}).finally(() => { queued -= payload.length; });
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
/** What the pending bytes give: the rest of a chunk under way, or a chunk's header and as much of its payload as came.
|
|
267
|
+
* How many bytes it took, or 0 when no more can be read yet. */
|
|
268
|
+
const readChunk = (): number => {
|
|
269
|
+
const b = pending;
|
|
270
|
+
if (current) {
|
|
271
|
+
const n = Math.min(current.left, b.length);
|
|
272
|
+
if (n === 0) return 0;
|
|
273
|
+
const { st } = current;
|
|
274
|
+
if (!absorb(st, b.subarray(0, n))) return b.length;
|
|
275
|
+
current.left -= n;
|
|
276
|
+
if (current.left === 0) { current = undefined; finish(st); }
|
|
277
|
+
return n;
|
|
278
|
+
}
|
|
279
|
+
if (b.length < 1) return 0;
|
|
280
|
+
const fmt = b[0]! >> 6;
|
|
281
|
+
let csid = b[0]! & 0x3f;
|
|
282
|
+
let at = 1;
|
|
283
|
+
if (csid === 0) { if (b.length < 2) return 0; csid = 64 + b[1]!; at = 2; }
|
|
284
|
+
else if (csid === 1) { if (b.length < 3) return 0; csid = 64 + b[1]! + b[2]! * 256; at = 3; }
|
|
285
|
+
const headerLength = [11, 7, 3, 0][fmt]!;
|
|
286
|
+
if (b.length < at + headerLength) return 0;
|
|
287
|
+
const prior = chunks.get(csid);
|
|
288
|
+
if (!prior && chunks.size >= MAX_CHUNK_STREAMS) { overrun(`it opened more than ${MAX_CHUNK_STREAMS} chunk streams`); return b.length; }
|
|
289
|
+
const st: ChunkState = prior ?? { timestamp: 0, tsField: 0, extended: false, length: 0, type: 0, streamId: 0, parts: [], received: 0 };
|
|
290
|
+
let tsField = st.tsField;
|
|
291
|
+
let length = st.length;
|
|
292
|
+
let type = st.type;
|
|
293
|
+
let streamId = st.streamId;
|
|
294
|
+
if (fmt <= 2) tsField = u24(b, at);
|
|
295
|
+
if (fmt <= 1) { length = u24(b, at + 3); type = b[at + 6]!; }
|
|
296
|
+
if (fmt === 0) streamId = u32le(b, at + 7);
|
|
297
|
+
at += headerLength;
|
|
298
|
+
// the extended timestamp: after a header whose field is 0xffffff, and in each type-3 chunk of a chunk stream whose
|
|
299
|
+
// last header had one (RTMP 1.0 §5.3.1.3), as ffmpeg and librtmp send it
|
|
300
|
+
const extended = fmt === 3 ? st.extended : tsField === 0xffffff;
|
|
301
|
+
let ts = tsField;
|
|
302
|
+
if (extended) { if (b.length < at + 4) return 0; ts = u32(b, at); at += 4; }
|
|
303
|
+
const starting = st.received === 0;
|
|
304
|
+
const largest = publishing ? MAX_MESSAGE : MAX_MESSAGE_UNADMITTED;
|
|
305
|
+
if (starting && length > largest) { overrun(`a message of ${length} bytes is past the ${largest} it may send now`); return b.length; }
|
|
306
|
+
if (!starting && length < st.received) { overrun('a chunk header shortened the message under way'); return b.length; }
|
|
307
|
+
// the header is the chunk stream's now
|
|
308
|
+
if (starting) {
|
|
309
|
+
// a type-0 header's timestamp is absolute; every other is a delta on the chunk stream's last (a type-3 header's is
|
|
310
|
+
// the last header's field, as ffmpeg reads it)
|
|
311
|
+
st.timestamp = fmt === 0 ? ts : st.timestamp + ts;
|
|
312
|
+
}
|
|
313
|
+
if (fmt <= 2) { st.tsField = tsField; st.extended = extended; }
|
|
314
|
+
st.length = length; st.type = type; st.streamId = streamId;
|
|
315
|
+
if (!prior) chunks.set(csid, st);
|
|
316
|
+
const take = Math.min(inChunk, length - st.received);
|
|
317
|
+
const n = Math.min(take, b.length - at);
|
|
318
|
+
if (!absorb(st, b.subarray(at, at + n))) return b.length;
|
|
319
|
+
if (n < take) current = { st, left: take - n };
|
|
320
|
+
else finish(st);
|
|
321
|
+
return at + n;
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const pump = (): void => {
|
|
325
|
+
for (;;) {
|
|
326
|
+
if (phase === 'closed') { pending = new Uint8Array(0); return; }
|
|
327
|
+
if (phase === 'c0c1') {
|
|
328
|
+
if (pending.length < 1 + HANDSHAKE) return;
|
|
329
|
+
if (pending[0] !== 3) { log(`RTMP version ${pending[0]} is not 3`); close(); return; }
|
|
330
|
+
const c1 = pending.subarray(1, 1 + HANDSHAKE);
|
|
331
|
+
// S0 (version 3), S1 (time 0, zero, filler), S2 (C1 echoed): the simple handshake
|
|
332
|
+
const s = new Uint8Array(1 + HANDSHAKE * 2);
|
|
333
|
+
s[0] = 3;
|
|
334
|
+
s.set(c1, 1 + HANDSHAKE);
|
|
335
|
+
write(s);
|
|
336
|
+
pending = pending.slice(1 + HANDSHAKE);
|
|
337
|
+
phase = 'c2';
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
if (phase === 'c2') {
|
|
341
|
+
if (pending.length < HANDSHAKE) return;
|
|
342
|
+
pending = pending.slice(HANDSHAKE);
|
|
343
|
+
phase = 'chunks';
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
const took = readChunk();
|
|
347
|
+
if (took === 0) return;
|
|
348
|
+
pending = pending.subarray(took);
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
return {
|
|
353
|
+
data(chunk) {
|
|
354
|
+
if (phase === 'closed') return;
|
|
355
|
+
received += chunk.length;
|
|
356
|
+
// the acknowledgement the peer's window asks for (RTMP 1.0 §5.4.3)
|
|
357
|
+
if (peerWindow > 0 && received - acked >= peerWindow) { acked = received; control(3, be32(received >>> 0)); }
|
|
358
|
+
// what is left unread is at most a chunk header (a chunk's payload is taken as it comes), so the copy is small
|
|
359
|
+
const rest = pending;
|
|
360
|
+
pending = new Uint8Array(rest.length + chunk.length);
|
|
361
|
+
pending.set(rest, 0); pending.set(chunk, rest.length);
|
|
362
|
+
pump();
|
|
363
|
+
},
|
|
364
|
+
settled: () => chain,
|
|
365
|
+
// what the twin's work has not reached: whole messages waiting and the few bytes not yet read. An unfinished
|
|
366
|
+
// message's bytes are not in it: only more bytes finish it, and what it may hold is bounded (MAX_HELD)
|
|
367
|
+
backlog: () => queued + pending.length,
|
|
368
|
+
close() {
|
|
369
|
+
clearTimeout(deadline);
|
|
370
|
+
// the connection ended: a publish still open ends with it, after every message already read
|
|
371
|
+
chain = chain.then(() => stop()).catch((error) => log(`the publish's end failed: ${String((error as Error)?.message ?? error)}`));
|
|
372
|
+
phase = 'closed';
|
|
373
|
+
},
|
|
374
|
+
};
|
|
375
|
+
}
|