nixamp 0.16.0 → 0.17.0
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/README.md +139 -0
- package/dist/audio.d.ts +70 -1
- package/dist/audio.js +84 -5
- package/dist/channels.d.ts +107 -3
- package/dist/channels.js +151 -3
- package/dist/compression/analyze.d.ts +105 -0
- package/dist/compression/analyze.js +213 -0
- package/dist/compression/blocks.d.ts +35 -0
- package/dist/compression/blocks.js +73 -0
- package/dist/compression/cli.d.ts +1 -0
- package/dist/compression/cli.js +347 -0
- package/dist/compression/codec.d.ts +66 -0
- package/dist/compression/codec.js +178 -0
- package/dist/compression/envelope.d.ts +77 -0
- package/dist/compression/envelope.js +191 -0
- package/dist/compression/jobs.d.ts +59 -0
- package/dist/compression/jobs.js +120 -0
- package/dist/compression/metrics.d.ts +62 -0
- package/dist/compression/metrics.js +64 -0
- package/dist/compression/policy.d.ts +76 -0
- package/dist/compression/policy.js +148 -0
- package/dist/compression/receiver.d.ts +51 -0
- package/dist/compression/receiver.js +92 -0
- package/dist/compression/relay.d.ts +134 -0
- package/dist/compression/relay.js +430 -0
- package/dist/compression/routes.d.ts +25 -0
- package/dist/compression/routes.js +266 -0
- package/dist/compression/service.d.ts +162 -0
- package/dist/compression/service.js +488 -0
- package/dist/compression/static.d.ts +65 -0
- package/dist/compression/static.js +248 -0
- package/dist/compression/store.d.ts +36 -0
- package/dist/compression/store.js +119 -0
- package/dist/compression/ts-transform.d.ts +44 -0
- package/dist/compression/ts-transform.js +148 -0
- package/dist/hls.d.ts +36 -3
- package/dist/hls.js +85 -11
- package/dist/layouts.d.ts +10 -0
- package/dist/layouts.js +71 -1
- package/dist/live-api.d.ts +16 -2
- package/dist/live-api.js +152 -19
- package/dist/live-events.d.ts +57 -2
- package/dist/live-events.js +243 -11
- package/dist/main.js +47 -0
- package/dist/mcp.d.ts +40 -0
- package/dist/mcp.js +255 -0
- package/dist/oauth-api.d.ts +50 -0
- package/dist/oauth-api.js +381 -0
- package/dist/oauth-server.d.ts +174 -0
- package/dist/oauth-server.js +559 -0
- package/dist/party.d.ts +32 -0
- package/dist/party.js +196 -0
- package/dist/playlist.d.ts +11 -0
- package/dist/playlist.js +27 -6
- package/dist/server.d.ts +37 -0
- package/dist/server.js +277 -45
- package/dist/sources.d.ts +16 -8
- package/dist/sources.js +102 -0
- package/dist/tickets.d.ts +67 -0
- package/dist/tickets.js +156 -0
- package/dist/tokens.d.ts +27 -2
- package/dist/tokens.js +42 -1
- package/dist/watch-party.d.ts +127 -0
- package/dist/watch-party.js +301 -0
- package/package.json +1 -1
- package/src/audio.ts +119 -6
- package/src/channels.ts +177 -7
- package/src/compression/analyze.ts +270 -0
- package/src/compression/blocks.ts +93 -0
- package/src/compression/cli.ts +340 -0
- package/src/compression/codec.ts +202 -0
- package/src/compression/envelope.ts +237 -0
- package/src/compression/jobs.ts +157 -0
- package/src/compression/metrics.ts +109 -0
- package/src/compression/policy.ts +171 -0
- package/src/compression/receiver.ts +107 -0
- package/src/compression/relay.ts +462 -0
- package/src/compression/routes.ts +294 -0
- package/src/compression/service.ts +525 -0
- package/src/compression/static.ts +264 -0
- package/src/compression/store.ts +126 -0
- package/src/compression/ts-transform.ts +148 -0
- package/src/hls.ts +100 -10
- package/src/layouts.ts +78 -1
- package/src/live-api.ts +172 -17
- package/src/live-events.ts +269 -11
- package/src/main.ts +47 -0
- package/src/mcp.ts +282 -0
- package/src/oauth-api.ts +481 -0
- package/src/oauth-server.ts +653 -0
- package/src/party.ts +216 -0
- package/src/playlist.ts +28 -5
- package/src/server.ts +296 -44
- package/src/sources.ts +100 -0
- package/src/tickets.ts +195 -0
- package/src/tokens.ts +50 -3
- package/src/watch-party.ts +390 -0
- package/web/dist/assets/{hls-3VKVEQE3-CrILISPJ.js → hls-3VKVEQE3-eV54kXE3.js} +1 -1
- package/web/dist/assets/index-CurZFzlH.css +1 -0
- package/web/dist/assets/index-DDzutJ75.js +1 -0
- package/web/dist/assets/{mpegts-CWeN63eG.js → mpegts-Buc3Odv6.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-DaMgRvlO.js → mpegts-LO6RVLD6-DcDKPB4P.js} +1 -1
- package/web/dist/index.html +59 -6
- package/web/dist/sw.js +6 -6
- package/web/dist/assets/index-CQ_m5HqS.css +0 -1
- package/web/dist/assets/index-CTxPM5KS.js +0 -1
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wire shape of a compressed relay: what one nixamp sends another.
|
|
3
|
+
*
|
|
4
|
+
* Not a .ts file and not an HTTP body somebody's proxy might gunzip: a
|
|
5
|
+
* stream of framed blocks, each one independently decodable, each one
|
|
6
|
+
* carrying the length and the SHA-256 of the bytes it stands for, ending in
|
|
7
|
+
* a marker that says the stream finished rather than dropped. The layout is
|
|
8
|
+
* specified byte by byte in docs/stream-compression.md; this file is that
|
|
9
|
+
* document as code, and the test vectors there are checked against it.
|
|
10
|
+
*
|
|
11
|
+
* Everything is big-endian. Every length is checked against the negotiated
|
|
12
|
+
* limit before a byte is allocated for it, so a hostile header cannot ask
|
|
13
|
+
* for a gigabyte.
|
|
14
|
+
*/
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
export const MAGIC = "NXS1";
|
|
17
|
+
export const ENVELOPE_VERSION = 1;
|
|
18
|
+
export const MEDIA_TYPE = "application/vnd.nixamp.stream";
|
|
19
|
+
export const STREAM_HEADER_BYTES = 16;
|
|
20
|
+
export const FRAME_HEADER_BYTES = 48;
|
|
21
|
+
const BOUNDARY_CODE = { source: 0, channel: 1 };
|
|
22
|
+
export const MODE_CODE = { stored: 0, zstd: 1, gzip: 2, "ts-zstd": 3 };
|
|
23
|
+
export const MODES = ["stored", "zstd", "gzip", "ts-zstd"];
|
|
24
|
+
export const FRAME_DATA = 1;
|
|
25
|
+
export const FRAME_END = 2;
|
|
26
|
+
/** The decoded size one frame may claim, unless negotiated otherwise. */
|
|
27
|
+
export const DEFAULT_MAX_FRAME_BYTES = 256 * 1024;
|
|
28
|
+
/** The most a stream header may negotiate, whatever it asks for. */
|
|
29
|
+
export const CEILING_FRAME_BYTES = 16 * 1024 * 1024;
|
|
30
|
+
/**
|
|
31
|
+
* How much bigger than its original an encoded payload may be. A codec that
|
|
32
|
+
* cannot beat stored is stored, so anything past a small fixed allowance is
|
|
33
|
+
* either a bug or an attack.
|
|
34
|
+
*/
|
|
35
|
+
export const EXPANSION_ALLOWANCE = 1024;
|
|
36
|
+
/** A stream that broke one of the rules, and which rule. Never silently. */
|
|
37
|
+
export class RelayError extends Error {
|
|
38
|
+
code;
|
|
39
|
+
constructor(code, message) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.code = code;
|
|
42
|
+
this.name = "RelayError";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export function sha256(bytes) {
|
|
46
|
+
return createHash("sha256").update(bytes).digest();
|
|
47
|
+
}
|
|
48
|
+
export function encodeStreamHeader(header) {
|
|
49
|
+
if (header.version !== ENVELOPE_VERSION)
|
|
50
|
+
throw new RelayError("BAD_VERSION", `cannot write version ${header.version}`);
|
|
51
|
+
if (!(header.boundary in BOUNDARY_CODE))
|
|
52
|
+
throw new RelayError("BAD_BOUNDARY", `no such boundary: ${header.boundary}`);
|
|
53
|
+
if (!Number.isInteger(header.generation) || header.generation < 0 || header.generation > 0xffff_ffff) {
|
|
54
|
+
throw new RelayError("BAD_LIMIT", "generation must fit in 32 bits");
|
|
55
|
+
}
|
|
56
|
+
if (!Number.isInteger(header.maxFrameBytes) || header.maxFrameBytes < 1 || header.maxFrameBytes > CEILING_FRAME_BYTES) {
|
|
57
|
+
throw new RelayError("BAD_LIMIT", `maxFrameBytes must be 1..${CEILING_FRAME_BYTES}`);
|
|
58
|
+
}
|
|
59
|
+
const out = Buffer.alloc(STREAM_HEADER_BYTES);
|
|
60
|
+
out.write(MAGIC, 0, 4, "latin1");
|
|
61
|
+
out.writeUInt8(header.version, 4);
|
|
62
|
+
out.writeUInt8(0, 5); // flags, none defined
|
|
63
|
+
out.writeUInt8(BOUNDARY_CODE[header.boundary], 6);
|
|
64
|
+
out.writeUInt8(0, 7); // reserved
|
|
65
|
+
out.writeUInt32BE(header.generation, 8);
|
|
66
|
+
out.writeUInt32BE(header.maxFrameBytes, 12);
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
/** Read a stream header from the front of `bytes`. Throws on anything off. */
|
|
70
|
+
export function decodeStreamHeader(bytes) {
|
|
71
|
+
if (bytes.length < STREAM_HEADER_BYTES)
|
|
72
|
+
throw new RelayError("TRUNCATED", "stream header is short");
|
|
73
|
+
if (bytes.toString("latin1", 0, 4) !== MAGIC)
|
|
74
|
+
throw new RelayError("BAD_MAGIC", "not a nixamp relay stream");
|
|
75
|
+
const version = bytes.readUInt8(4);
|
|
76
|
+
if (version !== ENVELOPE_VERSION)
|
|
77
|
+
throw new RelayError("BAD_VERSION", `envelope version ${version} is not understood`);
|
|
78
|
+
const boundaryCode = bytes.readUInt8(6);
|
|
79
|
+
const boundary = Object.keys(BOUNDARY_CODE).find((b) => BOUNDARY_CODE[b] === boundaryCode);
|
|
80
|
+
if (!boundary)
|
|
81
|
+
throw new RelayError("BAD_BOUNDARY", `boundary code ${boundaryCode} is not understood`);
|
|
82
|
+
const generation = bytes.readUInt32BE(8);
|
|
83
|
+
const maxFrameBytes = bytes.readUInt32BE(12);
|
|
84
|
+
if (maxFrameBytes < 1 || maxFrameBytes > CEILING_FRAME_BYTES) {
|
|
85
|
+
throw new RelayError("BAD_LIMIT", `maxFrameBytes ${maxFrameBytes} is outside 1..${CEILING_FRAME_BYTES}`);
|
|
86
|
+
}
|
|
87
|
+
return { version, boundary, generation, maxFrameBytes };
|
|
88
|
+
}
|
|
89
|
+
export function encodeFrameHeader(header) {
|
|
90
|
+
if (header.type !== FRAME_DATA && header.type !== FRAME_END)
|
|
91
|
+
throw new RelayError("BAD_FRAME_TYPE", `no such frame type ${header.type}`);
|
|
92
|
+
if (!(header.mode in MODE_CODE))
|
|
93
|
+
throw new RelayError("BAD_MODE", `no such mode: ${header.mode}`);
|
|
94
|
+
if (header.sha256.length !== 32)
|
|
95
|
+
throw new RelayError("CHECKSUM_MISMATCH", "sha256 must be 32 bytes");
|
|
96
|
+
for (const [name, value] of [["seq", header.seq], ["originalLength", header.originalLength], ["encodedLength", header.encodedLength]]) {
|
|
97
|
+
if (!Number.isInteger(value) || value < 0 || value > 0xffff_ffff)
|
|
98
|
+
throw new RelayError("BAD_LIMIT", `${name} must fit in 32 bits`);
|
|
99
|
+
}
|
|
100
|
+
const out = Buffer.alloc(FRAME_HEADER_BYTES);
|
|
101
|
+
out.writeUInt8(header.type, 0);
|
|
102
|
+
out.writeUInt8(MODE_CODE[header.mode], 1);
|
|
103
|
+
out.writeUInt16BE(0, 2); // reserved
|
|
104
|
+
out.writeUInt32BE(header.seq, 4);
|
|
105
|
+
out.writeUInt32BE(header.originalLength, 8);
|
|
106
|
+
out.writeUInt32BE(header.encodedLength, 12);
|
|
107
|
+
header.sha256.copy(out, 16);
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
/** A data frame: header then payload, as one buffer. */
|
|
111
|
+
export function encodeDataFrame(seq, mode, original, encoded) {
|
|
112
|
+
return Buffer.concat([dataFrameHeader(seq, mode, original, encoded.length), encoded]);
|
|
113
|
+
}
|
|
114
|
+
/** Just the header of a data frame, for a payload that is shared between listeners. */
|
|
115
|
+
export function dataFrameHeader(seq, mode, original, encodedLength, digest) {
|
|
116
|
+
return encodeFrameHeader({
|
|
117
|
+
type: FRAME_DATA,
|
|
118
|
+
mode,
|
|
119
|
+
seq,
|
|
120
|
+
originalLength: original.length,
|
|
121
|
+
encodedLength,
|
|
122
|
+
sha256: digest ?? sha256(original),
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The end marker: no payload, the generation's total original byte count in
|
|
127
|
+
* the two length fields (high word, low word) and the SHA-256 of every
|
|
128
|
+
* original byte in order. A stream that stops without one was cut off.
|
|
129
|
+
*/
|
|
130
|
+
export function encodeEndFrame(seq, totalOriginalBytes, digest) {
|
|
131
|
+
if (!Number.isSafeInteger(totalOriginalBytes) || totalOriginalBytes < 0)
|
|
132
|
+
throw new RelayError("BAD_LIMIT", "total must be a non-negative integer");
|
|
133
|
+
return encodeFrameHeader({
|
|
134
|
+
type: FRAME_END,
|
|
135
|
+
mode: "stored",
|
|
136
|
+
seq,
|
|
137
|
+
originalLength: Math.floor(totalOriginalBytes / 0x1_0000_0000),
|
|
138
|
+
encodedLength: totalOriginalBytes % 0x1_0000_0000,
|
|
139
|
+
sha256: digest,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
/** The total an end frame carries, from its two halves. */
|
|
143
|
+
export function endFrameTotal(header) {
|
|
144
|
+
return header.originalLength * 0x1_0000_0000 + header.encodedLength;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Read a frame header, checking every field against the limits before the
|
|
148
|
+
* caller allocates anything for the payload.
|
|
149
|
+
*/
|
|
150
|
+
export function decodeFrameHeader(bytes, limits) {
|
|
151
|
+
if (bytes.length < FRAME_HEADER_BYTES)
|
|
152
|
+
throw new RelayError("TRUNCATED", "frame header is short");
|
|
153
|
+
const type = bytes.readUInt8(0);
|
|
154
|
+
if (type !== FRAME_DATA && type !== FRAME_END)
|
|
155
|
+
throw new RelayError("BAD_FRAME_TYPE", `frame type ${type} is not understood`);
|
|
156
|
+
const modeCode = bytes.readUInt8(1);
|
|
157
|
+
const mode = MODES.find((m) => MODE_CODE[m] === modeCode);
|
|
158
|
+
if (!mode)
|
|
159
|
+
throw new RelayError("BAD_MODE", `mode code ${modeCode} is not understood`);
|
|
160
|
+
const seq = bytes.readUInt32BE(4);
|
|
161
|
+
if (seq !== limits.expectSeq)
|
|
162
|
+
throw new RelayError("BAD_SEQUENCE", `expected frame ${limits.expectSeq}, got ${seq}`);
|
|
163
|
+
const originalLength = bytes.readUInt32BE(8);
|
|
164
|
+
const encodedLength = bytes.readUInt32BE(12);
|
|
165
|
+
const sha = Buffer.from(bytes.subarray(16, 48));
|
|
166
|
+
if (type === FRAME_END) {
|
|
167
|
+
return { type: FRAME_END, mode: "stored", seq, originalLength, encodedLength, sha256: sha };
|
|
168
|
+
}
|
|
169
|
+
if (!limits.modes.has(mode))
|
|
170
|
+
throw new RelayError("UNSUPPORTED_MODE", `this decoder cannot undo ${mode}`);
|
|
171
|
+
if (originalLength > limits.maxFrameBytes) {
|
|
172
|
+
throw new RelayError("FRAME_TOO_LARGE", `frame claims ${originalLength} original bytes, limit ${limits.maxFrameBytes}`);
|
|
173
|
+
}
|
|
174
|
+
if (encodedLength > originalLength + EXPANSION_ALLOWANCE) {
|
|
175
|
+
throw new RelayError("EXPANSION_BUDGET", `frame carries ${encodedLength} bytes for ${originalLength} original`);
|
|
176
|
+
}
|
|
177
|
+
if (mode === "stored" && encodedLength !== originalLength) {
|
|
178
|
+
throw new RelayError("LENGTH_MISMATCH", "a stored frame must carry exactly its original bytes");
|
|
179
|
+
}
|
|
180
|
+
return { type: FRAME_DATA, mode, seq, originalLength, encodedLength, sha256: sha };
|
|
181
|
+
}
|
|
182
|
+
/** The codec names a peer lists in a negotiation header, kept to the ones we know. */
|
|
183
|
+
export function parseModes(header) {
|
|
184
|
+
const out = new Set();
|
|
185
|
+
for (const raw of (header ?? "").split(",")) {
|
|
186
|
+
const name = raw.trim().toLowerCase();
|
|
187
|
+
if (MODES.includes(name))
|
|
188
|
+
out.add(name);
|
|
189
|
+
}
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Analysis } from "./analyze.ts";
|
|
2
|
+
export type JobStatus = "queued" | "running" | "done" | "failed" | "cancelled";
|
|
3
|
+
export interface Job {
|
|
4
|
+
id: string;
|
|
5
|
+
/** What is being analysed, for the listing. */
|
|
6
|
+
subject: string;
|
|
7
|
+
/** Who may see it: the scope that started it. */
|
|
8
|
+
owner: string;
|
|
9
|
+
status: JobStatus;
|
|
10
|
+
createdAt: number;
|
|
11
|
+
startedAt?: number;
|
|
12
|
+
finishedAt?: number;
|
|
13
|
+
progress: {
|
|
14
|
+
bytes: number;
|
|
15
|
+
ms: number;
|
|
16
|
+
};
|
|
17
|
+
result?: Analysis;
|
|
18
|
+
error?: string;
|
|
19
|
+
}
|
|
20
|
+
export interface JobsOptions {
|
|
21
|
+
concurrency?: number;
|
|
22
|
+
/** How long a finished job is kept. */
|
|
23
|
+
ttlMs?: number;
|
|
24
|
+
/** How many jobs, in any state, may exist at once. */
|
|
25
|
+
maxJobs?: number;
|
|
26
|
+
}
|
|
27
|
+
export type Runner = (signal: AbortSignal, progress: (bytes: number, ms: number) => void) => Promise<Analysis>;
|
|
28
|
+
export declare class AnalysisJobs {
|
|
29
|
+
private readonly slots;
|
|
30
|
+
private readonly queue;
|
|
31
|
+
private running;
|
|
32
|
+
private readonly concurrency;
|
|
33
|
+
private readonly ttlMs;
|
|
34
|
+
private readonly maxJobs;
|
|
35
|
+
constructor(options?: JobsOptions);
|
|
36
|
+
/**
|
|
37
|
+
* Start a job, or return the one already doing the same thing. `key`
|
|
38
|
+
* names the thing: the same key while a job is queued or running is the
|
|
39
|
+
* same job. Null when the server has all the jobs it will hold.
|
|
40
|
+
*/
|
|
41
|
+
start(key: string, subject: string, owner: string, run: Runner): {
|
|
42
|
+
job: Job;
|
|
43
|
+
existing: boolean;
|
|
44
|
+
} | null;
|
|
45
|
+
/** A job, if it exists and `owner` may see it. */
|
|
46
|
+
get(id: string, owner: string): Job | null;
|
|
47
|
+
cancel(id: string, owner: string): boolean;
|
|
48
|
+
list(owner: string): Job[];
|
|
49
|
+
get counts(): {
|
|
50
|
+
running: number;
|
|
51
|
+
queued: number;
|
|
52
|
+
kept: number;
|
|
53
|
+
};
|
|
54
|
+
/** Stop everything, for a server going down. */
|
|
55
|
+
stopAll(): void;
|
|
56
|
+
private pump;
|
|
57
|
+
/** Forget finished jobs older than the TTL. */
|
|
58
|
+
private sweep;
|
|
59
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diagnostic jobs: bounded, deduplicated, cancellable, forgotten in time.
|
|
3
|
+
*
|
|
4
|
+
* An analysis reads up to thirty seconds or twenty-five megabytes of a
|
|
5
|
+
* channel and runs every codec over it, which is real work. So there is a
|
|
6
|
+
* ceiling on how many run at once, a second request for the same thing
|
|
7
|
+
* while the first is still going is handed the first, and a finished
|
|
8
|
+
* result is kept for a while and then dropped rather than for ever.
|
|
9
|
+
*/
|
|
10
|
+
import { randomBytes } from "node:crypto";
|
|
11
|
+
export class AnalysisJobs {
|
|
12
|
+
slots = new Map();
|
|
13
|
+
queue = [];
|
|
14
|
+
running = 0;
|
|
15
|
+
concurrency;
|
|
16
|
+
ttlMs;
|
|
17
|
+
maxJobs;
|
|
18
|
+
constructor(options = {}) {
|
|
19
|
+
this.concurrency = Math.max(1, options.concurrency ?? 1);
|
|
20
|
+
this.ttlMs = options.ttlMs ?? 10 * 60_000;
|
|
21
|
+
this.maxJobs = options.maxJobs ?? 32;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Start a job, or return the one already doing the same thing. `key`
|
|
25
|
+
* names the thing: the same key while a job is queued or running is the
|
|
26
|
+
* same job. Null when the server has all the jobs it will hold.
|
|
27
|
+
*/
|
|
28
|
+
start(key, subject, owner, run) {
|
|
29
|
+
this.sweep();
|
|
30
|
+
for (const slot of this.slots.values()) {
|
|
31
|
+
if (slot.key === key && (slot.job.status === "queued" || slot.job.status === "running"))
|
|
32
|
+
return { job: slot.job, existing: true };
|
|
33
|
+
}
|
|
34
|
+
if (this.slots.size >= this.maxJobs)
|
|
35
|
+
return null;
|
|
36
|
+
const job = {
|
|
37
|
+
id: `a${randomBytes(6).toString("hex")}`,
|
|
38
|
+
subject,
|
|
39
|
+
owner,
|
|
40
|
+
status: "queued",
|
|
41
|
+
createdAt: Date.now(),
|
|
42
|
+
progress: { bytes: 0, ms: 0 },
|
|
43
|
+
};
|
|
44
|
+
const slot = { job, key, controller: new AbortController(), run };
|
|
45
|
+
this.slots.set(job.id, slot);
|
|
46
|
+
this.queue.push(slot);
|
|
47
|
+
this.pump();
|
|
48
|
+
return { job, existing: false };
|
|
49
|
+
}
|
|
50
|
+
/** A job, if it exists and `owner` may see it. */
|
|
51
|
+
get(id, owner) {
|
|
52
|
+
this.sweep();
|
|
53
|
+
const slot = this.slots.get(id);
|
|
54
|
+
if (!slot || slot.job.owner !== owner)
|
|
55
|
+
return null;
|
|
56
|
+
return slot.job;
|
|
57
|
+
}
|
|
58
|
+
cancel(id, owner) {
|
|
59
|
+
const slot = this.slots.get(id);
|
|
60
|
+
if (!slot || slot.job.owner !== owner)
|
|
61
|
+
return false;
|
|
62
|
+
if (slot.job.status !== "queued" && slot.job.status !== "running")
|
|
63
|
+
return false;
|
|
64
|
+
slot.controller.abort();
|
|
65
|
+
if (slot.job.status === "queued") {
|
|
66
|
+
const at = this.queue.indexOf(slot);
|
|
67
|
+
if (at !== -1)
|
|
68
|
+
this.queue.splice(at, 1);
|
|
69
|
+
slot.job.status = "cancelled";
|
|
70
|
+
slot.job.finishedAt = Date.now();
|
|
71
|
+
}
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
list(owner) {
|
|
75
|
+
this.sweep();
|
|
76
|
+
return [...this.slots.values()].map((slot) => slot.job).filter((job) => job.owner === owner);
|
|
77
|
+
}
|
|
78
|
+
get counts() {
|
|
79
|
+
return { running: this.running, queued: this.queue.length, kept: this.slots.size };
|
|
80
|
+
}
|
|
81
|
+
/** Stop everything, for a server going down. */
|
|
82
|
+
stopAll() {
|
|
83
|
+
for (const slot of this.slots.values())
|
|
84
|
+
slot.controller.abort();
|
|
85
|
+
this.queue.length = 0;
|
|
86
|
+
}
|
|
87
|
+
pump() {
|
|
88
|
+
while (this.running < this.concurrency && this.queue.length > 0) {
|
|
89
|
+
const slot = this.queue.shift();
|
|
90
|
+
this.running += 1;
|
|
91
|
+
slot.job.status = "running";
|
|
92
|
+
slot.job.startedAt = Date.now();
|
|
93
|
+
void slot
|
|
94
|
+
.run(slot.controller.signal, (bytes, ms) => {
|
|
95
|
+
slot.job.progress = { bytes, ms };
|
|
96
|
+
})
|
|
97
|
+
.then((result) => {
|
|
98
|
+
slot.job.result = result;
|
|
99
|
+
slot.job.status = slot.controller.signal.aborted ? "cancelled" : "done";
|
|
100
|
+
})
|
|
101
|
+
.catch((error) => {
|
|
102
|
+
slot.job.status = slot.controller.signal.aborted ? "cancelled" : "failed";
|
|
103
|
+
slot.job.error = error.message;
|
|
104
|
+
})
|
|
105
|
+
.finally(() => {
|
|
106
|
+
slot.job.finishedAt = Date.now();
|
|
107
|
+
this.running -= 1;
|
|
108
|
+
this.pump();
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/** Forget finished jobs older than the TTL. */
|
|
113
|
+
sweep() {
|
|
114
|
+
const cutoff = Date.now() - this.ttlMs;
|
|
115
|
+
for (const [id, slot] of this.slots) {
|
|
116
|
+
if (slot.job.finishedAt !== undefined && slot.job.finishedAt < cutoff)
|
|
117
|
+
this.slots.delete(id);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What was measured, per channel, and only what was measured.
|
|
3
|
+
*
|
|
4
|
+
* Input bytes are what the channel produced. Wire bytes are what went to
|
|
5
|
+
* relay listeners, headers included, counted once per listener because
|
|
6
|
+
* each listener is a separate cost on the network. Neither is a saving
|
|
7
|
+
* until it is compared with the other, and a block that was stored saved
|
|
8
|
+
* nothing -- these counters say so rather than counting what compression
|
|
9
|
+
* might have done.
|
|
10
|
+
*/
|
|
11
|
+
export type FallbackReason = "compression is off" | "already efficiently compressed" | "receiver does not support this format" | "processing budget exceeded" | "original source bytes are unavailable" | "slow listener";
|
|
12
|
+
export interface ChannelMetricsSnapshot {
|
|
13
|
+
generation: number;
|
|
14
|
+
/** Bytes in from the channel since this generation began. */
|
|
15
|
+
inputBytes: number;
|
|
16
|
+
/** Payload bytes out, before fan-out: what one listener would receive. */
|
|
17
|
+
representationBytes: number;
|
|
18
|
+
/** Payload plus headers, summed over every listener that was sent it. */
|
|
19
|
+
wireBytes: number;
|
|
20
|
+
blocks: number;
|
|
21
|
+
storedBlocks: number;
|
|
22
|
+
compressedBlocks: number;
|
|
23
|
+
/** Which codec produced the most recent compressed block, or stored. */
|
|
24
|
+
activeMode: string;
|
|
25
|
+
bypassed: boolean;
|
|
26
|
+
bypassUntil: number | null;
|
|
27
|
+
/** From the first byte of a block entering to its being ready, milliseconds. */
|
|
28
|
+
latencyMs: {
|
|
29
|
+
last: number;
|
|
30
|
+
p50: number;
|
|
31
|
+
p95: number;
|
|
32
|
+
max: number;
|
|
33
|
+
samples: number;
|
|
34
|
+
};
|
|
35
|
+
queueBytes: number;
|
|
36
|
+
listeners: number;
|
|
37
|
+
droppedListeners: number;
|
|
38
|
+
codecFailures: number;
|
|
39
|
+
fallbackReason: FallbackReason | null;
|
|
40
|
+
}
|
|
41
|
+
export declare class ChannelMetrics {
|
|
42
|
+
generation: number;
|
|
43
|
+
inputBytes: number;
|
|
44
|
+
representationBytes: number;
|
|
45
|
+
wireBytes: number;
|
|
46
|
+
blocks: number;
|
|
47
|
+
storedBlocks: number;
|
|
48
|
+
compressedBlocks: number;
|
|
49
|
+
activeMode: string;
|
|
50
|
+
bypassed: boolean;
|
|
51
|
+
bypassUntil: number | null;
|
|
52
|
+
queueBytes: number;
|
|
53
|
+
listeners: number;
|
|
54
|
+
droppedListeners: number;
|
|
55
|
+
codecFailures: number;
|
|
56
|
+
fallbackReason: FallbackReason | null;
|
|
57
|
+
private latencies;
|
|
58
|
+
latency(ms: number): void;
|
|
59
|
+
/** A new run of the source: the per-generation counters start over. */
|
|
60
|
+
reset(generation: number): void;
|
|
61
|
+
snapshot(): ChannelMetricsSnapshot;
|
|
62
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
const LATENCY_WINDOW = 512;
|
|
2
|
+
export class ChannelMetrics {
|
|
3
|
+
generation = 0;
|
|
4
|
+
inputBytes = 0;
|
|
5
|
+
representationBytes = 0;
|
|
6
|
+
wireBytes = 0;
|
|
7
|
+
blocks = 0;
|
|
8
|
+
storedBlocks = 0;
|
|
9
|
+
compressedBlocks = 0;
|
|
10
|
+
activeMode = "stored";
|
|
11
|
+
bypassed = false;
|
|
12
|
+
bypassUntil = null;
|
|
13
|
+
queueBytes = 0;
|
|
14
|
+
listeners = 0;
|
|
15
|
+
droppedListeners = 0;
|
|
16
|
+
codecFailures = 0;
|
|
17
|
+
fallbackReason = null;
|
|
18
|
+
latencies = [];
|
|
19
|
+
latency(ms) {
|
|
20
|
+
this.latencies.push(ms);
|
|
21
|
+
if (this.latencies.length > LATENCY_WINDOW)
|
|
22
|
+
this.latencies.shift();
|
|
23
|
+
}
|
|
24
|
+
/** A new run of the source: the per-generation counters start over. */
|
|
25
|
+
reset(generation) {
|
|
26
|
+
this.generation = generation;
|
|
27
|
+
this.inputBytes = 0;
|
|
28
|
+
this.representationBytes = 0;
|
|
29
|
+
this.wireBytes = 0;
|
|
30
|
+
this.blocks = 0;
|
|
31
|
+
this.storedBlocks = 0;
|
|
32
|
+
this.compressedBlocks = 0;
|
|
33
|
+
this.queueBytes = 0;
|
|
34
|
+
this.latencies = [];
|
|
35
|
+
}
|
|
36
|
+
snapshot() {
|
|
37
|
+
const sorted = [...this.latencies].sort((a, b) => a - b);
|
|
38
|
+
const at = (q) => (sorted.length === 0 ? 0 : sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))]);
|
|
39
|
+
return {
|
|
40
|
+
generation: this.generation,
|
|
41
|
+
inputBytes: this.inputBytes,
|
|
42
|
+
representationBytes: this.representationBytes,
|
|
43
|
+
wireBytes: this.wireBytes,
|
|
44
|
+
blocks: this.blocks,
|
|
45
|
+
storedBlocks: this.storedBlocks,
|
|
46
|
+
compressedBlocks: this.compressedBlocks,
|
|
47
|
+
activeMode: this.activeMode,
|
|
48
|
+
bypassed: this.bypassed,
|
|
49
|
+
bypassUntil: this.bypassUntil,
|
|
50
|
+
latencyMs: {
|
|
51
|
+
last: sorted.length === 0 ? 0 : this.latencies[this.latencies.length - 1],
|
|
52
|
+
p50: at(0.5),
|
|
53
|
+
p95: at(0.95),
|
|
54
|
+
max: sorted.length === 0 ? 0 : sorted[sorted.length - 1],
|
|
55
|
+
samples: sorted.length,
|
|
56
|
+
},
|
|
57
|
+
queueBytes: this.queueBytes,
|
|
58
|
+
listeners: this.listeners,
|
|
59
|
+
droppedListeners: this.droppedListeners,
|
|
60
|
+
codecFailures: this.codecFailures,
|
|
61
|
+
fallbackReason: this.fallbackReason,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an operator can set, and what a block has to achieve to be sent
|
|
3
|
+
* compressed.
|
|
4
|
+
*
|
|
5
|
+
* Three settings that must never be confused: whether to squeeze the bytes
|
|
6
|
+
* losslessly, how to package HLS, and whether to re-encode the picture.
|
|
7
|
+
* Turning one on never turns another on. The first is the only one this
|
|
8
|
+
* module measures; the other two are carried here so that one document
|
|
9
|
+
* describes a channel, and so that the API can refuse a change that mixes
|
|
10
|
+
* them up.
|
|
11
|
+
*/
|
|
12
|
+
import { type Boundary } from "./envelope.ts";
|
|
13
|
+
export type PolicyMode = "off" | "auto" | "zstd";
|
|
14
|
+
export type HlsPackaging = "mpegts" | "fmp4";
|
|
15
|
+
/** `source` is the media as it came. Anything else re-encodes and says so. */
|
|
16
|
+
export type QualityProfile = "source";
|
|
17
|
+
export interface LosslessPolicy {
|
|
18
|
+
mode: PolicyMode;
|
|
19
|
+
boundary: Boundary;
|
|
20
|
+
zstdLevel: number;
|
|
21
|
+
/** A block is sent compressed only if it saves at least this much, both ways. */
|
|
22
|
+
minSavingsPercent: number;
|
|
23
|
+
minSavingsBytes: number;
|
|
24
|
+
maxBlockBytes: number;
|
|
25
|
+
/** From the first byte entering a block to its being flushed, at most. */
|
|
26
|
+
maxHoldMs: number;
|
|
27
|
+
/** After `auto` has given up on a source, how long before it tries again. */
|
|
28
|
+
resampleAfterMs: number;
|
|
29
|
+
/** Bytes the shared compressor may hold for a channel before it stops that relay. */
|
|
30
|
+
maxChannelQueueBytes: number;
|
|
31
|
+
/** Unsent bytes one relay listener may fall behind by before it is cut off. */
|
|
32
|
+
maxListenerQueueBytes: number;
|
|
33
|
+
/** The experimental transport-stream transform. Off unless asked for. */
|
|
34
|
+
tsAware: boolean;
|
|
35
|
+
}
|
|
36
|
+
export interface ChannelPolicy {
|
|
37
|
+
losslessCompression: LosslessPolicy;
|
|
38
|
+
hlsPackaging: HlsPackaging;
|
|
39
|
+
qualityProfile: QualityProfile;
|
|
40
|
+
/** Bumped on every accepted change; a conditional update names the one it saw. */
|
|
41
|
+
version: number;
|
|
42
|
+
}
|
|
43
|
+
export declare const DEFAULT_LOSSLESS: LosslessPolicy;
|
|
44
|
+
export declare const DEFAULT_POLICY: ChannelPolicy;
|
|
45
|
+
/** Fresh copies, so nobody edits the defaults in place. */
|
|
46
|
+
export declare function defaultPolicy(): ChannelPolicy;
|
|
47
|
+
export type Normalized = {
|
|
48
|
+
ok: true;
|
|
49
|
+
policy: ChannelPolicy;
|
|
50
|
+
} | {
|
|
51
|
+
ok: false;
|
|
52
|
+
errors: string[];
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* A change, applied over what is there, checked field by field.
|
|
56
|
+
*
|
|
57
|
+
* Unknown keys are errors rather than ignored: a typo that is silently
|
|
58
|
+
* dropped is a setting the operator believes is on and is not. The version
|
|
59
|
+
* is not settable here; the store bumps it when it accepts the result.
|
|
60
|
+
*/
|
|
61
|
+
export declare function normalizePolicy(input: unknown, base: ChannelPolicy): Normalized;
|
|
62
|
+
/**
|
|
63
|
+
* Whether a compressed block earns its place.
|
|
64
|
+
*
|
|
65
|
+
* Both representations carry the same frame header, so the comparison of
|
|
66
|
+
* complete representations reduces to the payloads: the saving is what the
|
|
67
|
+
* original would have cost stored, less what the encoding costs. Both
|
|
68
|
+
* thresholds must hold. A tiny block can meet the percentage and still not
|
|
69
|
+
* be worth the decode; a huge one can save half a kilobyte and still be
|
|
70
|
+
* nothing.
|
|
71
|
+
*/
|
|
72
|
+
export declare function eligible(originalLength: number, encodedLength: number, policy: Pick<LosslessPolicy, "minSavingsPercent" | "minSavingsBytes">): boolean;
|
|
73
|
+
/** What a block costs on the wire in each form, header included. */
|
|
74
|
+
export declare function wireBytes(payloadLength: number): number;
|
|
75
|
+
/** Whether two policies would produce the same bytes for the same input. */
|
|
76
|
+
export declare function variantOf(policy: LosslessPolicy): string;
|