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,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nixamp compression` -- measure it, see it, set it, bring a relay in.
|
|
3
|
+
*
|
|
4
|
+
* nixamp compression analyze ./sample.ts [--seconds 30] [--format json]
|
|
5
|
+
* nixamp compression analyze --channel main [--seconds 30]
|
|
6
|
+
* nixamp compression status [--channel main]
|
|
7
|
+
* nixamp compression set --channel main --mode auto [--level 1] [--ts-aware on]
|
|
8
|
+
* nixamp compression set --channel main --hls fmp4
|
|
9
|
+
* nixamp compression off | on
|
|
10
|
+
* nixamp compression pull --channel cnn --from https://host:4321/api/channels/cnn/relay --from-key KEY
|
|
11
|
+
* nixamp compression fetch URL --out FILE [--key KEY]
|
|
12
|
+
*
|
|
13
|
+
* A file is analysed here, with no server. Everything else talks to a
|
|
14
|
+
* running server over the same routes a browser would: the local daemon
|
|
15
|
+
* by default, or --url and --key for another machine, exactly as
|
|
16
|
+
* `nixamp admin` finds its target. JSON goes to stdout and only JSON;
|
|
17
|
+
* progress and complaints go to stderr; a failure is a nonzero exit.
|
|
18
|
+
*/
|
|
19
|
+
import { createWriteStream } from "node:fs";
|
|
20
|
+
import { resolveTarget } from "../admin.js";
|
|
21
|
+
import { detectTools } from "../audio.js";
|
|
22
|
+
import { KEY_HEADER } from "../share.js";
|
|
23
|
+
import { RelayError } from "./envelope.js";
|
|
24
|
+
import { receiveRelay, RelayRefused } from "./receiver.js";
|
|
25
|
+
import { analyzeFile } from "./service.js";
|
|
26
|
+
const USAGE = `nixamp compression — lossless relay compression: measure it, see it, set it.
|
|
27
|
+
|
|
28
|
+
nixamp compression analyze FILE [--seconds N] [--format json|text]
|
|
29
|
+
nixamp compression analyze --channel ID [--seconds N]
|
|
30
|
+
nixamp compression status [--channel ID]
|
|
31
|
+
nixamp compression set --channel ID [--mode off|auto|zstd] [--level 1-19]
|
|
32
|
+
[--boundary channel|source] [--ts-aware on|off] [--hls mpegts|fmp4]
|
|
33
|
+
nixamp compression off | on the whole server's switch
|
|
34
|
+
nixamp compression pull --channel ID --from URL [--from-key KEY] [--name NAME]
|
|
35
|
+
nixamp compression fetch URL --out FILE [--key KEY]
|
|
36
|
+
|
|
37
|
+
--url U --key K a server other than the local daemon, as for \`nixamp admin\`
|
|
38
|
+
--format json JSON on stdout (the default when stdout is not a terminal)
|
|
39
|
+
`;
|
|
40
|
+
function parse(argv) {
|
|
41
|
+
const positional = [];
|
|
42
|
+
const named = new Map();
|
|
43
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
44
|
+
const arg = argv[i];
|
|
45
|
+
if (arg.startsWith("--")) {
|
|
46
|
+
const eq = arg.indexOf("=");
|
|
47
|
+
if (eq !== -1)
|
|
48
|
+
named.set(arg.slice(2, eq), arg.slice(eq + 1));
|
|
49
|
+
else if (i + 1 < argv.length && !argv[i + 1].startsWith("--"))
|
|
50
|
+
named.set(arg.slice(2), argv[(i += 1)]);
|
|
51
|
+
else
|
|
52
|
+
named.set(arg.slice(2), "true");
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
positional.push(arg);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return { positional, named };
|
|
59
|
+
}
|
|
60
|
+
function wantsJson(flags) {
|
|
61
|
+
const format = flags.named.get("format");
|
|
62
|
+
if (format === "json")
|
|
63
|
+
return true;
|
|
64
|
+
if (format === "text")
|
|
65
|
+
return false;
|
|
66
|
+
return !process.stdout.isTTY;
|
|
67
|
+
}
|
|
68
|
+
/** Talk to the server the way a browser does, key in the header. */
|
|
69
|
+
async function call(flags, method, path, body) {
|
|
70
|
+
const target = resolveTarget([
|
|
71
|
+
...(flags.named.has("url") ? ["--url", flags.named.get("url")] : []),
|
|
72
|
+
...(flags.named.has("key") ? ["--key", flags.named.get("key")] : []),
|
|
73
|
+
]);
|
|
74
|
+
const headers = { accept: "application/json" };
|
|
75
|
+
if (target.key)
|
|
76
|
+
headers[KEY_HEADER] = target.key;
|
|
77
|
+
if (body !== undefined)
|
|
78
|
+
headers["content-type"] = "application/json";
|
|
79
|
+
const response = await fetch(`${target.url}${path}`, { method, headers, ...(body !== undefined ? { body: JSON.stringify(body) } : {}) });
|
|
80
|
+
let parsedBody = {};
|
|
81
|
+
try {
|
|
82
|
+
parsedBody = (await response.json());
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
parsedBody = { error: `${response.status} ${response.statusText}` };
|
|
86
|
+
}
|
|
87
|
+
return { status: response.status, body: parsedBody };
|
|
88
|
+
}
|
|
89
|
+
function kb(bytes) {
|
|
90
|
+
if (bytes < 1024)
|
|
91
|
+
return `${bytes} B`;
|
|
92
|
+
if (bytes < 1024 * 1024)
|
|
93
|
+
return `${(bytes / 1024).toFixed(1)} KiB`;
|
|
94
|
+
return `${(bytes / 1024 / 1024).toFixed(2)} MiB`;
|
|
95
|
+
}
|
|
96
|
+
function describeAnalysis(a) {
|
|
97
|
+
const lines = [
|
|
98
|
+
` ${a.source.kind} ${a.source.name}`,
|
|
99
|
+
` boundary ${a.boundary}; sample ${kb(a.sampleBytes)}${a.truncated ? " (truncated)" : ""}${a.sampleMs ? ` over ${(a.sampleMs / 1000).toFixed(1)}s` : ""}; container ${a.container}`,
|
|
100
|
+
];
|
|
101
|
+
if (a.codecs)
|
|
102
|
+
lines.push(` ffprobe: ${a.codecs.container} video=${a.codecs.video || "-"} audio=${a.codecs.audio || "-"}${a.codecs.duration ? ` ${a.codecs.duration.toFixed(0)}s` : ""}`);
|
|
103
|
+
if (a.observedKbps)
|
|
104
|
+
lines.push(` observed ${a.observedKbps} kbps`);
|
|
105
|
+
if (a.ts) {
|
|
106
|
+
lines.push(` transport stream: ${a.ts.packetSize}-byte packets, ${a.ts.packets} packets, ${(a.ts.nullShare * 100).toFixed(1)}% null, ${a.ts.pids.length} PIDs${a.ts.pcrBitrateKbps ? `, PCR says ${a.ts.pcrBitrateKbps} kbps` : ""}${a.ts.scrambled ? `, ${a.ts.scrambled} scrambled` : ""}`);
|
|
107
|
+
}
|
|
108
|
+
lines.push(" mode level wire bytes saving enc ms dec ms round trip");
|
|
109
|
+
for (const row of a.bench) {
|
|
110
|
+
lines.push(` ${row.mode.padEnd(9)} ${String(row.level).padStart(5)} ${String(row.wireBytes).padStart(10)} ${(row.savingsPercent >= 0 ? "+" : "") + row.savingsPercent.toFixed(2).padStart(6)}% ${row.encodeMs.toFixed(1).padStart(6)} ${row.decodeMs.toFixed(1).padStart(6)} ${row.roundTrip ? "ok" : "FAILED"}${row.note ? ` (${row.note})` : ""}`);
|
|
111
|
+
}
|
|
112
|
+
lines.push(` recommendation: ${a.recommendation.mode}${a.recommendation.level ? ` level ${a.recommendation.level}` : ""} — ${a.recommendation.reason}`);
|
|
113
|
+
lines.push(` ${a.tools.runtime}, zstd ${a.tools.zstd}, sha256 ${a.sha256.slice(0, 16)}…, ${a.at}`);
|
|
114
|
+
return lines.join("\n");
|
|
115
|
+
}
|
|
116
|
+
function describeStatus(s) {
|
|
117
|
+
const c = s.configured.losslessCompression;
|
|
118
|
+
const e = s.effective.losslessCompression;
|
|
119
|
+
const lines = [
|
|
120
|
+
` ${s.channel}${s.live ? "" : " (not on the air)"}`,
|
|
121
|
+
` lossless: configured ${c.mode} at ${c.boundary}, level ${c.zstdLevel}${c.tsAware ? ", ts-aware" : ""}; effective ${e.mode}${s.effective.reason ? ` — ${s.effective.reason}` : ""}`,
|
|
122
|
+
` hls packaging: ${s.effective.hlsPackaging}; quality: ${s.effective.qualityProfile}; policy version ${s.configured.version}`,
|
|
123
|
+
` server switch: ${s.global.enabled ? "on" : "OFF"}`,
|
|
124
|
+
];
|
|
125
|
+
if (s.relay)
|
|
126
|
+
lines.push(` relaying to ${s.relay.sessions} receiver${s.relay.sessions === 1 ? "" : "s"}, generation ${s.relay.generation}`);
|
|
127
|
+
if (s.incoming)
|
|
128
|
+
lines.push(` fed by ${s.incoming.from} (generation ${s.incoming.generation}, ${s.incoming.reconnects} redials${s.incoming.error ? `, last: ${s.incoming.error}` : ""})`);
|
|
129
|
+
const m = s.metrics;
|
|
130
|
+
if (m && m.blocks > 0) {
|
|
131
|
+
const saved = m.inputBytes - m.representationBytes;
|
|
132
|
+
lines.push(` this generation: in ${kb(m.inputBytes)}, representation ${kb(m.representationBytes)} (${saved >= 0 ? "-" : "+"}${((Math.abs(saved) * 100) / Math.max(1, m.inputBytes)).toFixed(1)}%), wire ${kb(m.wireBytes)} across listeners`, ` blocks ${m.blocks}: ${m.compressedBlocks} ${m.activeMode === "stored" ? "compressed" : m.activeMode}, ${m.storedBlocks} stored${m.bypassed ? " (bypassed)" : ""}; latency p50 ${m.latencyMs.p50.toFixed(1)}ms p95 ${m.latencyMs.p95.toFixed(1)}ms; queue ${kb(m.queueBytes)}`);
|
|
133
|
+
if (m.fallbackReason)
|
|
134
|
+
lines.push(` reason: ${m.fallbackReason}`);
|
|
135
|
+
}
|
|
136
|
+
return lines.join("\n");
|
|
137
|
+
}
|
|
138
|
+
async function analyze(flags) {
|
|
139
|
+
const json = wantsJson(flags);
|
|
140
|
+
const channel = flags.named.get("channel");
|
|
141
|
+
const seconds = Number(flags.named.get("seconds") ?? 30);
|
|
142
|
+
if (!channel) {
|
|
143
|
+
const file = flags.positional[1];
|
|
144
|
+
if (!file) {
|
|
145
|
+
console.error(USAGE);
|
|
146
|
+
return 2;
|
|
147
|
+
}
|
|
148
|
+
console.error(` Reading ${file}…`);
|
|
149
|
+
const tools = detectTools();
|
|
150
|
+
const result = await analyzeFile(file, { ffprobe: tools.ffprobe });
|
|
151
|
+
console.log(json ? JSON.stringify(result, null, 2) : describeAnalysis(result));
|
|
152
|
+
return 0;
|
|
153
|
+
}
|
|
154
|
+
const started = await call(flags, "POST", `/api/channels/${encodeURIComponent(channel)}/compression/analyses`, { seconds });
|
|
155
|
+
if (started.status !== 202 && started.status !== 200) {
|
|
156
|
+
console.error(`nixamp: ${started.body["error"] ?? started.status}`);
|
|
157
|
+
return 1;
|
|
158
|
+
}
|
|
159
|
+
let job = started.body["job"];
|
|
160
|
+
console.error(` Analysis ${job.id} ${started.body["existing"] ? "already running" : "started"}: up to ${seconds}s of "${channel}"…`);
|
|
161
|
+
while (job.status === "queued" || job.status === "running") {
|
|
162
|
+
await new Promise((done) => setTimeout(done, 1000));
|
|
163
|
+
const poll = await call(flags, "GET", `/api/compression/analyses/${job.id}`);
|
|
164
|
+
if (poll.status !== 200) {
|
|
165
|
+
console.error(`nixamp: ${poll.body["error"] ?? poll.status}`);
|
|
166
|
+
return 1;
|
|
167
|
+
}
|
|
168
|
+
job = poll.body["job"];
|
|
169
|
+
if (job.status === "running")
|
|
170
|
+
process.stderr.write(`\r ${kb(job.progress.bytes)} in ${(job.progress.ms / 1000).toFixed(0)}s`);
|
|
171
|
+
}
|
|
172
|
+
process.stderr.write("\n");
|
|
173
|
+
if (job.status !== "done" || !job.result) {
|
|
174
|
+
console.error(`nixamp: analysis ${job.status}${job.error ? `: ${job.error}` : ""}`);
|
|
175
|
+
return 1;
|
|
176
|
+
}
|
|
177
|
+
console.log(json ? JSON.stringify(job.result, null, 2) : describeAnalysis(job.result));
|
|
178
|
+
return 0;
|
|
179
|
+
}
|
|
180
|
+
async function status(flags) {
|
|
181
|
+
const json = wantsJson(flags);
|
|
182
|
+
const channel = flags.named.get("channel");
|
|
183
|
+
const got = await call(flags, "GET", channel ? `/api/channels/${encodeURIComponent(channel)}/compression` : "/api/compression");
|
|
184
|
+
if (got.status !== 200) {
|
|
185
|
+
console.error(`nixamp: ${got.body["error"] ?? got.status}`);
|
|
186
|
+
return 1;
|
|
187
|
+
}
|
|
188
|
+
if (json) {
|
|
189
|
+
console.log(JSON.stringify(got.body, null, 2));
|
|
190
|
+
return 0;
|
|
191
|
+
}
|
|
192
|
+
if (channel) {
|
|
193
|
+
console.log(describeStatus(got.body));
|
|
194
|
+
return 0;
|
|
195
|
+
}
|
|
196
|
+
const overview = got.body;
|
|
197
|
+
console.log(` server switch ${overview.global.enabled ? "on" : "OFF"}; hls ${overview.global.hlsPackaging}; pool running ${overview.pool["running"]} queued ${overview.pool["queued"]}${overview.cache ? `; cache ${kb(overview.cache.bytes)} in ${overview.cache.entries} files` : ""}`);
|
|
198
|
+
for (const one of overview.channels)
|
|
199
|
+
console.log(describeStatus(one));
|
|
200
|
+
if (overview.channels.length === 0)
|
|
201
|
+
console.log(" no channels with a policy or on the air");
|
|
202
|
+
return 0;
|
|
203
|
+
}
|
|
204
|
+
async function set(flags) {
|
|
205
|
+
const channel = flags.named.get("channel");
|
|
206
|
+
if (!channel) {
|
|
207
|
+
console.error("nixamp: say which channel: --channel ID");
|
|
208
|
+
return 2;
|
|
209
|
+
}
|
|
210
|
+
const lossless = {};
|
|
211
|
+
const change = {};
|
|
212
|
+
const mode = flags.named.get("mode");
|
|
213
|
+
if (mode)
|
|
214
|
+
lossless["mode"] = mode;
|
|
215
|
+
const boundary = flags.named.get("boundary");
|
|
216
|
+
if (boundary)
|
|
217
|
+
lossless["boundary"] = boundary;
|
|
218
|
+
const level = flags.named.get("level");
|
|
219
|
+
if (level)
|
|
220
|
+
lossless["zstdLevel"] = Number(level);
|
|
221
|
+
const ts = flags.named.get("ts-aware");
|
|
222
|
+
if (ts)
|
|
223
|
+
lossless["tsAware"] = ts === "on" || ts === "true";
|
|
224
|
+
for (const [flag, key] of [["min-savings-percent", "minSavingsPercent"], ["min-savings-bytes", "minSavingsBytes"], ["max-block-bytes", "maxBlockBytes"], ["max-hold-ms", "maxHoldMs"]]) {
|
|
225
|
+
const value = flags.named.get(flag);
|
|
226
|
+
if (value)
|
|
227
|
+
lossless[key] = Number(value);
|
|
228
|
+
}
|
|
229
|
+
if (Object.keys(lossless).length > 0)
|
|
230
|
+
change["losslessCompression"] = lossless;
|
|
231
|
+
const hls = flags.named.get("hls");
|
|
232
|
+
if (hls)
|
|
233
|
+
change["hlsPackaging"] = hls;
|
|
234
|
+
if (Object.keys(change).length === 0) {
|
|
235
|
+
console.error("nixamp: nothing to set; see `nixamp compression --help`");
|
|
236
|
+
return 2;
|
|
237
|
+
}
|
|
238
|
+
const expect = flags.named.get("expect-version");
|
|
239
|
+
const result = await call(flags, "PATCH", `/api/channels/${encodeURIComponent(channel)}/compression`, expect ? { ...change, version: Number(expect) } : change);
|
|
240
|
+
if (result.status !== 200) {
|
|
241
|
+
console.error(`nixamp: ${result.body["error"] ?? result.status}`);
|
|
242
|
+
return 1;
|
|
243
|
+
}
|
|
244
|
+
console.log(wantsJson(flags) ? JSON.stringify(result.body, null, 2) : describeStatus(result.body));
|
|
245
|
+
return 0;
|
|
246
|
+
}
|
|
247
|
+
async function toggle(flags, enabled) {
|
|
248
|
+
const result = await call(flags, "PATCH", "/api/compression", { enabled });
|
|
249
|
+
if (result.status !== 200) {
|
|
250
|
+
console.error(`nixamp: ${result.body["error"] ?? result.status}`);
|
|
251
|
+
return 1;
|
|
252
|
+
}
|
|
253
|
+
console.log(wantsJson(flags) ? JSON.stringify(result.body, null, 2) : ` compression is ${enabled ? "on: channels follow their own policies" : "OFF for the whole server: no new relay starts, and running ones end"}`);
|
|
254
|
+
return 0;
|
|
255
|
+
}
|
|
256
|
+
async function pull(flags) {
|
|
257
|
+
const channel = flags.named.get("channel");
|
|
258
|
+
const from = flags.named.get("from");
|
|
259
|
+
if (!channel || !from) {
|
|
260
|
+
console.error("nixamp: say which channel and where from: --channel ID --from URL");
|
|
261
|
+
return 2;
|
|
262
|
+
}
|
|
263
|
+
const result = await call(flags, "POST", `/api/channels/${encodeURIComponent(channel)}/relay`, {
|
|
264
|
+
from,
|
|
265
|
+
...(flags.named.has("from-key") ? { key: flags.named.get("from-key") } : {}),
|
|
266
|
+
...(flags.named.has("name") ? { name: flags.named.get("name") } : {}),
|
|
267
|
+
});
|
|
268
|
+
if (result.status !== 202) {
|
|
269
|
+
console.error(`nixamp: ${result.body["error"] ?? result.status}`);
|
|
270
|
+
return 1;
|
|
271
|
+
}
|
|
272
|
+
console.log(wantsJson(flags) ? JSON.stringify(result.body, null, 2) : ` "${channel}" is being brought in from ${from}; \`nixamp compression status --channel ${channel}\` says how it is going`);
|
|
273
|
+
return 0;
|
|
274
|
+
}
|
|
275
|
+
/** Receive one relay (or a static representation) into a file, checking every frame. */
|
|
276
|
+
async function fetchRelay(flags) {
|
|
277
|
+
const url = flags.positional[1];
|
|
278
|
+
const out = flags.named.get("out");
|
|
279
|
+
if (!url || !out) {
|
|
280
|
+
console.error("nixamp: fetch URL --out FILE");
|
|
281
|
+
return 2;
|
|
282
|
+
}
|
|
283
|
+
const file = createWriteStream(out);
|
|
284
|
+
let bytes = 0;
|
|
285
|
+
const began = Date.now();
|
|
286
|
+
try {
|
|
287
|
+
const result = await receiveRelay({
|
|
288
|
+
url,
|
|
289
|
+
key: flags.named.get("key") ?? null,
|
|
290
|
+
onStart: ({ codecs, kind }) => console.error(` Receiving${kind ? ` ${kind}` : ""} with ${codecs || "stored"}…`),
|
|
291
|
+
onBytes: (chunk) => new Promise((done) => {
|
|
292
|
+
bytes += chunk.length;
|
|
293
|
+
if (bytes % (1024 * 1024) < chunk.length)
|
|
294
|
+
process.stderr.write(`\r ${kb(bytes)}`);
|
|
295
|
+
if (file.write(chunk))
|
|
296
|
+
done();
|
|
297
|
+
else
|
|
298
|
+
file.once("drain", done);
|
|
299
|
+
}),
|
|
300
|
+
});
|
|
301
|
+
await new Promise((done) => file.end(done));
|
|
302
|
+
process.stderr.write("\n");
|
|
303
|
+
console.log(JSON.stringify({ ok: true, out, bytes: result.bytes, frames: result.frames, generation: result.generation, ms: Date.now() - began }));
|
|
304
|
+
return 0;
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
file.destroy();
|
|
308
|
+
process.stderr.write("\n");
|
|
309
|
+
if (error instanceof RelayError)
|
|
310
|
+
console.error(`nixamp: the stream broke a rule: ${error.code}: ${error.message}`);
|
|
311
|
+
else if (error instanceof RelayRefused)
|
|
312
|
+
console.error(`nixamp: refused (${error.status}): ${error.message}`);
|
|
313
|
+
else
|
|
314
|
+
console.error(`nixamp: ${error.message}`);
|
|
315
|
+
return 1;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
export async function compression(argv) {
|
|
319
|
+
const flags = parse(argv);
|
|
320
|
+
const verb = flags.positional[0];
|
|
321
|
+
try {
|
|
322
|
+
switch (verb) {
|
|
323
|
+
case "analyze":
|
|
324
|
+
case "analyse":
|
|
325
|
+
return await analyze(flags);
|
|
326
|
+
case "status":
|
|
327
|
+
return await status(flags);
|
|
328
|
+
case "set":
|
|
329
|
+
return await set(flags);
|
|
330
|
+
case "off":
|
|
331
|
+
return await toggle(flags, false);
|
|
332
|
+
case "on":
|
|
333
|
+
return await toggle(flags, true);
|
|
334
|
+
case "pull":
|
|
335
|
+
return await pull(flags);
|
|
336
|
+
case "fetch":
|
|
337
|
+
return await fetchRelay(flags);
|
|
338
|
+
default:
|
|
339
|
+
console.error(USAGE);
|
|
340
|
+
return verb === undefined || verb === "help" || flags.named.has("help") ? 0 : 2;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
catch (error) {
|
|
344
|
+
console.error(`nixamp: ${error.message}`);
|
|
345
|
+
return 1;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { type Mode } from "./envelope.ts";
|
|
2
|
+
export declare const MIN_ZSTD_LEVEL = 1;
|
|
3
|
+
export declare const MAX_ZSTD_LEVEL = 19;
|
|
4
|
+
/** Encode `bytes` in `mode`. Stored is the identity, so it never goes through the pool. */
|
|
5
|
+
export declare function encode(mode: Mode, bytes: Buffer, level?: number): Promise<Buffer>;
|
|
6
|
+
/**
|
|
7
|
+
* Decode a payload back to its original bytes. `maxOutputLength` is the
|
|
8
|
+
* decoded size the frame header promised; a payload that wants to be bigger
|
|
9
|
+
* than that is refused before it can be.
|
|
10
|
+
*/
|
|
11
|
+
export declare function decode(mode: Mode, bytes: Buffer, maxOutputLength: number): Promise<Buffer>;
|
|
12
|
+
/** What did the work, for a benchmark that has to be reproducible. */
|
|
13
|
+
export declare function toolVersions(): {
|
|
14
|
+
runtime: string;
|
|
15
|
+
zstd: string;
|
|
16
|
+
zlib: string;
|
|
17
|
+
};
|
|
18
|
+
export interface PoolOptions {
|
|
19
|
+
/** How many jobs may be in flight at once. */
|
|
20
|
+
concurrency?: number;
|
|
21
|
+
/** How many may wait their turn before a new one is refused. */
|
|
22
|
+
maxQueued?: number;
|
|
23
|
+
/** How long one job may take before it is abandoned. */
|
|
24
|
+
timeoutMs?: number;
|
|
25
|
+
}
|
|
26
|
+
export interface PoolStats {
|
|
27
|
+
running: number;
|
|
28
|
+
queued: number;
|
|
29
|
+
completed: number;
|
|
30
|
+
refused: number;
|
|
31
|
+
timedOut: number;
|
|
32
|
+
failed: number;
|
|
33
|
+
}
|
|
34
|
+
/** Thrown for a job the pool would not take, or would not wait for. */
|
|
35
|
+
export declare class PoolError extends Error {
|
|
36
|
+
readonly code: "BUSY" | "TIMEOUT" | "CANCELLED";
|
|
37
|
+
constructor(code: "BUSY" | "TIMEOUT" | "CANCELLED", message: string);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* A bounded queue in front of the codecs.
|
|
41
|
+
*
|
|
42
|
+
* The codecs themselves already run on other threads; what would hurt is a
|
|
43
|
+
* thousand blocks queued behind a slow one, each holding a quarter of a
|
|
44
|
+
* megabyte. So there is a ceiling on the queue, and a job past the deadline
|
|
45
|
+
* is dropped by whoever asked for it -- the thread finishes and its result
|
|
46
|
+
* is thrown away, which for a block of at most a quarter of a megabyte is a
|
|
47
|
+
* few milliseconds wasted, not a leak.
|
|
48
|
+
*/
|
|
49
|
+
export declare class Pool {
|
|
50
|
+
private readonly concurrency;
|
|
51
|
+
private readonly maxQueued;
|
|
52
|
+
private readonly timeoutMs;
|
|
53
|
+
private running;
|
|
54
|
+
private readonly waiting;
|
|
55
|
+
private readonly counts;
|
|
56
|
+
constructor(options?: PoolOptions);
|
|
57
|
+
get stats(): PoolStats;
|
|
58
|
+
run<T>(job: () => Promise<T>, options?: {
|
|
59
|
+
timeoutMs?: number;
|
|
60
|
+
signal?: AbortSignal;
|
|
61
|
+
}): Promise<T>;
|
|
62
|
+
/** Take a slot now, or wait for one to be handed over by `release`. */
|
|
63
|
+
private acquire;
|
|
64
|
+
/** Give the slot to the next in line, or back to the pool if nobody is waiting. */
|
|
65
|
+
private release;
|
|
66
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The codecs, and the pool that keeps them off the event loop.
|
|
3
|
+
*
|
|
4
|
+
* Zstandard and gzip come from node:zlib, which both Node 24 and Bun ship
|
|
5
|
+
* with libzstd built in: no native module to build, nothing to pin beyond
|
|
6
|
+
* the runtime the package already requires. The asynchronous calls run on
|
|
7
|
+
* the runtime's own thread pool, so a block being squeezed never holds a
|
|
8
|
+
* request. What this file adds is the discipline around them: a ceiling on
|
|
9
|
+
* how many run at once, a queue that refuses rather than grows, a deadline
|
|
10
|
+
* on every job, and a limit on how big a decode may get before it is called
|
|
11
|
+
* a bomb.
|
|
12
|
+
*/
|
|
13
|
+
import { constants, gunzip, gzip, zstdCompress, zstdDecompress } from "node:zlib";
|
|
14
|
+
import { RelayError } from "./envelope.js";
|
|
15
|
+
import { tsJoin, tsSplit } from "./ts-transform.js";
|
|
16
|
+
export const MIN_ZSTD_LEVEL = 1;
|
|
17
|
+
export const MAX_ZSTD_LEVEL = 19;
|
|
18
|
+
/** Encode `bytes` in `mode`. Stored is the identity, so it never goes through the pool. */
|
|
19
|
+
export function encode(mode, bytes, level = 1) {
|
|
20
|
+
switch (mode) {
|
|
21
|
+
case "stored":
|
|
22
|
+
return Promise.resolve(bytes);
|
|
23
|
+
case "zstd":
|
|
24
|
+
return zstd(bytes, level);
|
|
25
|
+
case "gzip":
|
|
26
|
+
return new Promise((resolve, reject) => gzip(bytes, { level: Math.min(9, Math.max(1, level)) }, (error, out) => (error ? reject(error) : resolve(out))));
|
|
27
|
+
case "ts-zstd": {
|
|
28
|
+
const split = tsSplit(bytes);
|
|
29
|
+
if (split === null)
|
|
30
|
+
return Promise.reject(new RelayError("BAD_MODE", "not a transport stream: ts-zstd does not apply"));
|
|
31
|
+
return zstd(split, level);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Decode a payload back to its original bytes. `maxOutputLength` is the
|
|
37
|
+
* decoded size the frame header promised; a payload that wants to be bigger
|
|
38
|
+
* than that is refused before it can be.
|
|
39
|
+
*/
|
|
40
|
+
export async function decode(mode, bytes, maxOutputLength) {
|
|
41
|
+
switch (mode) {
|
|
42
|
+
case "stored":
|
|
43
|
+
return bytes;
|
|
44
|
+
case "zstd":
|
|
45
|
+
return unzstd(bytes, maxOutputLength);
|
|
46
|
+
case "gzip":
|
|
47
|
+
return new Promise((resolve, reject) => gunzip(bytes, { maxOutputLength }, (error, out) => (error ? reject(bomb(error)) : resolve(out))));
|
|
48
|
+
case "ts-zstd": {
|
|
49
|
+
// The split form is a few bytes longer than the original, never shorter.
|
|
50
|
+
const split = await unzstd(bytes, maxOutputLength + 64);
|
|
51
|
+
const joined = tsJoin(split);
|
|
52
|
+
if (joined === null)
|
|
53
|
+
throw new RelayError("DECODE_FAILED", "ts-zstd payload did not join back into packets");
|
|
54
|
+
return joined;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function zstd(bytes, level) {
|
|
59
|
+
const params = { [constants.ZSTD_c_compressionLevel]: Math.min(MAX_ZSTD_LEVEL, Math.max(MIN_ZSTD_LEVEL, level)) };
|
|
60
|
+
return new Promise((resolve, reject) => zstdCompress(bytes, { params }, (error, out) => (error ? reject(error) : resolve(out))));
|
|
61
|
+
}
|
|
62
|
+
function unzstd(bytes, maxOutputLength) {
|
|
63
|
+
return new Promise((resolve, reject) => zstdDecompress(bytes, { maxOutputLength }, (error, out) => (error ? reject(bomb(error)) : resolve(out))));
|
|
64
|
+
}
|
|
65
|
+
/** A decode that failed is a decode that failed; a decode that grew past its limit is named as such. */
|
|
66
|
+
function bomb(error) {
|
|
67
|
+
if (error.code === "ERR_BUFFER_TOO_LARGE")
|
|
68
|
+
return new RelayError("FRAME_TOO_LARGE", "payload decodes to more than its frame promised");
|
|
69
|
+
return new RelayError("DECODE_FAILED", error.message);
|
|
70
|
+
}
|
|
71
|
+
/** What did the work, for a benchmark that has to be reproducible. */
|
|
72
|
+
export function toolVersions() {
|
|
73
|
+
const versions = process.versions;
|
|
74
|
+
return {
|
|
75
|
+
runtime: versions["bun"] ? `bun ${versions["bun"]}` : `node ${process.version}`,
|
|
76
|
+
zstd: versions["zstd"] ?? "bundled",
|
|
77
|
+
zlib: versions["zlib"] ?? "bundled",
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/** Thrown for a job the pool would not take, or would not wait for. */
|
|
81
|
+
export class PoolError extends Error {
|
|
82
|
+
code;
|
|
83
|
+
constructor(code, message) {
|
|
84
|
+
super(message);
|
|
85
|
+
this.code = code;
|
|
86
|
+
this.name = "PoolError";
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* A bounded queue in front of the codecs.
|
|
91
|
+
*
|
|
92
|
+
* The codecs themselves already run on other threads; what would hurt is a
|
|
93
|
+
* thousand blocks queued behind a slow one, each holding a quarter of a
|
|
94
|
+
* megabyte. So there is a ceiling on the queue, and a job past the deadline
|
|
95
|
+
* is dropped by whoever asked for it -- the thread finishes and its result
|
|
96
|
+
* is thrown away, which for a block of at most a quarter of a megabyte is a
|
|
97
|
+
* few milliseconds wasted, not a leak.
|
|
98
|
+
*/
|
|
99
|
+
export class Pool {
|
|
100
|
+
concurrency;
|
|
101
|
+
maxQueued;
|
|
102
|
+
timeoutMs;
|
|
103
|
+
running = 0;
|
|
104
|
+
waiting = [];
|
|
105
|
+
counts = { completed: 0, refused: 0, timedOut: 0, failed: 0 };
|
|
106
|
+
constructor(options = {}) {
|
|
107
|
+
this.concurrency = Math.max(1, options.concurrency ?? 4);
|
|
108
|
+
this.maxQueued = Math.max(0, options.maxQueued ?? 64);
|
|
109
|
+
this.timeoutMs = Math.max(1, options.timeoutMs ?? 2000);
|
|
110
|
+
}
|
|
111
|
+
get stats() {
|
|
112
|
+
return { running: this.running, queued: this.waiting.length, ...this.counts };
|
|
113
|
+
}
|
|
114
|
+
async run(job, options = {}) {
|
|
115
|
+
if (options.signal?.aborted)
|
|
116
|
+
throw new PoolError("CANCELLED", "cancelled before it started");
|
|
117
|
+
await this.acquire(options.signal);
|
|
118
|
+
const deadline = options.timeoutMs ?? this.timeoutMs;
|
|
119
|
+
let timer = null;
|
|
120
|
+
let onAbort = null;
|
|
121
|
+
try {
|
|
122
|
+
const result = await Promise.race([
|
|
123
|
+
job(),
|
|
124
|
+
new Promise((_, reject) => {
|
|
125
|
+
timer = setTimeout(() => reject(new PoolError("TIMEOUT", `took longer than ${deadline}ms`)), deadline);
|
|
126
|
+
timer.unref?.();
|
|
127
|
+
if (options.signal) {
|
|
128
|
+
onAbort = () => reject(new PoolError("CANCELLED", "cancelled"));
|
|
129
|
+
options.signal.addEventListener("abort", onAbort, { once: true });
|
|
130
|
+
}
|
|
131
|
+
}),
|
|
132
|
+
]);
|
|
133
|
+
this.counts.completed += 1;
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
if (error instanceof PoolError && error.code === "TIMEOUT")
|
|
138
|
+
this.counts.timedOut += 1;
|
|
139
|
+
else if (!(error instanceof PoolError))
|
|
140
|
+
this.counts.failed += 1;
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
if (timer)
|
|
145
|
+
clearTimeout(timer);
|
|
146
|
+
if (onAbort && options.signal)
|
|
147
|
+
options.signal.removeEventListener("abort", onAbort);
|
|
148
|
+
this.release();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/** Take a slot now, or wait for one to be handed over by `release`. */
|
|
152
|
+
async acquire(signal) {
|
|
153
|
+
if (this.running < this.concurrency) {
|
|
154
|
+
this.running += 1;
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (this.waiting.length >= this.maxQueued) {
|
|
158
|
+
this.counts.refused += 1;
|
|
159
|
+
throw new PoolError("BUSY", "the codec pool is full");
|
|
160
|
+
}
|
|
161
|
+
await new Promise((next) => this.waiting.push(next));
|
|
162
|
+
// Woken: the finishing job passed its slot to us without touching the
|
|
163
|
+
// count. If we no longer want it, pass it on the same way.
|
|
164
|
+
if (signal?.aborted) {
|
|
165
|
+
this.release();
|
|
166
|
+
throw new PoolError("CANCELLED", "cancelled while queued");
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/** Give the slot to the next in line, or back to the pool if nobody is waiting. */
|
|
170
|
+
release() {
|
|
171
|
+
const next = this.waiting.shift();
|
|
172
|
+
if (next) {
|
|
173
|
+
next();
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
this.running -= 1;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
export declare const MAGIC = "NXS1";
|
|
2
|
+
export declare const ENVELOPE_VERSION = 1;
|
|
3
|
+
export declare const MEDIA_TYPE = "application/vnd.nixamp.stream";
|
|
4
|
+
export declare const STREAM_HEADER_BYTES = 16;
|
|
5
|
+
export declare const FRAME_HEADER_BYTES = 48;
|
|
6
|
+
/** Where the bytes were captured: before ffmpeg, or after the channel pipeline. */
|
|
7
|
+
export type Boundary = "source" | "channel";
|
|
8
|
+
/** How one block's payload was encoded. `stored` is the bytes as they were. */
|
|
9
|
+
export type Mode = "stored" | "zstd" | "gzip" | "ts-zstd";
|
|
10
|
+
export declare const MODE_CODE: Record<Mode, number>;
|
|
11
|
+
export declare const MODES: readonly Mode[];
|
|
12
|
+
export declare const FRAME_DATA = 1;
|
|
13
|
+
export declare const FRAME_END = 2;
|
|
14
|
+
/** The decoded size one frame may claim, unless negotiated otherwise. */
|
|
15
|
+
export declare const DEFAULT_MAX_FRAME_BYTES: number;
|
|
16
|
+
/** The most a stream header may negotiate, whatever it asks for. */
|
|
17
|
+
export declare const CEILING_FRAME_BYTES: number;
|
|
18
|
+
/**
|
|
19
|
+
* How much bigger than its original an encoded payload may be. A codec that
|
|
20
|
+
* cannot beat stored is stored, so anything past a small fixed allowance is
|
|
21
|
+
* either a bug or an attack.
|
|
22
|
+
*/
|
|
23
|
+
export declare const EXPANSION_ALLOWANCE = 1024;
|
|
24
|
+
export interface StreamHeader {
|
|
25
|
+
version: number;
|
|
26
|
+
boundary: Boundary;
|
|
27
|
+
/** Which run of the source this is; changes on every restart. */
|
|
28
|
+
generation: number;
|
|
29
|
+
/** The decoded-size limit every frame in this stream honours. */
|
|
30
|
+
maxFrameBytes: number;
|
|
31
|
+
}
|
|
32
|
+
export interface FrameHeader {
|
|
33
|
+
type: typeof FRAME_DATA | typeof FRAME_END;
|
|
34
|
+
mode: Mode;
|
|
35
|
+
seq: number;
|
|
36
|
+
originalLength: number;
|
|
37
|
+
encodedLength: number;
|
|
38
|
+
/** Of the original bytes for a data frame; of the whole generation for the end. */
|
|
39
|
+
sha256: Buffer;
|
|
40
|
+
}
|
|
41
|
+
export type RelayErrorCode = "BAD_MAGIC" | "BAD_VERSION" | "BAD_BOUNDARY" | "BAD_LIMIT" | "BAD_FRAME_TYPE" | "BAD_MODE" | "UNSUPPORTED_MODE" | "BAD_SEQUENCE" | "FRAME_TOO_LARGE" | "EXPANSION_BUDGET" | "CHECKSUM_MISMATCH" | "LENGTH_MISMATCH" | "DECODE_FAILED" | "AFTER_END" | "TRUNCATED";
|
|
42
|
+
/** A stream that broke one of the rules, and which rule. Never silently. */
|
|
43
|
+
export declare class RelayError extends Error {
|
|
44
|
+
readonly code: RelayErrorCode;
|
|
45
|
+
constructor(code: RelayErrorCode, message: string);
|
|
46
|
+
}
|
|
47
|
+
export declare function sha256(bytes: Uint8Array): Buffer;
|
|
48
|
+
export declare function encodeStreamHeader(header: StreamHeader): Buffer;
|
|
49
|
+
/** Read a stream header from the front of `bytes`. Throws on anything off. */
|
|
50
|
+
export declare function decodeStreamHeader(bytes: Buffer): StreamHeader;
|
|
51
|
+
export declare function encodeFrameHeader(header: FrameHeader): Buffer;
|
|
52
|
+
/** A data frame: header then payload, as one buffer. */
|
|
53
|
+
export declare function encodeDataFrame(seq: number, mode: Mode, original: Buffer, encoded: Buffer): Buffer;
|
|
54
|
+
/** Just the header of a data frame, for a payload that is shared between listeners. */
|
|
55
|
+
export declare function dataFrameHeader(seq: number, mode: Mode, original: Buffer, encodedLength: number, digest?: Buffer): Buffer;
|
|
56
|
+
/**
|
|
57
|
+
* The end marker: no payload, the generation's total original byte count in
|
|
58
|
+
* the two length fields (high word, low word) and the SHA-256 of every
|
|
59
|
+
* original byte in order. A stream that stops without one was cut off.
|
|
60
|
+
*/
|
|
61
|
+
export declare function encodeEndFrame(seq: number, totalOriginalBytes: number, digest: Buffer): Buffer;
|
|
62
|
+
/** The total an end frame carries, from its two halves. */
|
|
63
|
+
export declare function endFrameTotal(header: FrameHeader): number;
|
|
64
|
+
export interface FrameLimits {
|
|
65
|
+
maxFrameBytes: number;
|
|
66
|
+
/** Modes this decoder can undo. A frame in any other mode is refused. */
|
|
67
|
+
modes: ReadonlySet<Mode>;
|
|
68
|
+
/** The sequence number expected next. */
|
|
69
|
+
expectSeq: number;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Read a frame header, checking every field against the limits before the
|
|
73
|
+
* caller allocates anything for the payload.
|
|
74
|
+
*/
|
|
75
|
+
export declare function decodeFrameHeader(bytes: Buffer, limits: FrameLimits): FrameHeader;
|
|
76
|
+
/** The codec names a peer lists in a negotiation header, kept to the ones we know. */
|
|
77
|
+
export declare function parseModes(header: string | undefined | null): Set<Mode>;
|