@torrent-tv/proxy 2.9.68 → 2.9.70

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/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ ## 2.9.70
2
+
3
+ - **Chore**: Instrumentation to settle where a slow transfer actually loses its time, instead of arguing about it. Every data-channel body transfer now reports the split — `readMs` (reading the body from the local route), `chanMs` (handing chunks to the channel), `drainMs` (waiting for the channel queue) — plus `rate` and, decisively, the **event-loop delay** over the same window (`loopMean`/`loopP99`/`loopMax`, via `perf_hooks.monitorEventLoopDelay`). Synchronous work blocking the loop looks exactly like a slow network from the outside; these figures tell them apart. Prompted by a field seek where a 9.4 MB segment took 16.5 s to deliver with the channel queue **empty the whole time** (`maxBuffered=0`) while the encoder ran at 14x realtime and the file was already on disk — so none of encoder, torrent or channel capacity explained it, and no measurement existed that could. New `utils/perf.js` (`OperationTimer`, `eventLoopDelay`); deeper tools (`--trace-events-enabled`, `--cpu-prof`) remain for when these point somewhere specific.
4
+
5
+ ## 2.9.69
6
+
7
+ - **Fix**: Removed the last traces of the seek-start "pull", so nothing can move the encode position except the viewer's own seek. Root cause now measured rather than guessed: **during a scrub the player loads from wherever the slider pauses on its way**. Browser log 2026-08-02 — dragging from 0 to 23:34 lingered at 863.4 s, the player fetched segment #82 for that intermediate point, and a seek that had correctly resolved to start at #134 was dragged back to **#82**, then crawled forward for a minute. The browser's 300 ms debounce exists precisely to discard intermediate scrub positions; reading them back off the segment-request stream defeated it. Gone with it: `lowestAwaitedIndex` tracking, `SEEK_PULL_LIMIT_SEGMENTS`, and the reset paths they needed.
8
+
1
9
  ## 2.9.68
2
10
 
3
11
  - **Fix**: A seek could be dragged back to the position the viewer had just left. The encoder start was pulled down to the lowest segment the player had outstanding — a stand-in from when the distance to the preceding keyframe was unknown — but at seek time those requests still describe where the player was PLAYING, not where it is going. Field 2026-08-02: a seek to 23:34 (#135) correctly resolved to a start of #134, then got pulled to **#82** (14:15, the position just left) and crawled forward from there. Removed: since boundaries became real keyframes (2.9.65), exactly one segment back always suffices, so the pull has nothing left to correct for.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.68",
3
+ "version": "2.9.70",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -1,469 +1,496 @@
1
- /**
2
- * @file WebRTC data channel request handler (proxy side).
3
- *
4
- * When a browser opens a data channel to this proxy, this handler wires up
5
- * message handlers that implement an HTTP-over-DataChannel protocol:
6
- * each incoming `request` message triggers a local `fetch` to the Fastify
7
- * server, and the response is streamed back as base64-encoded chunks.
8
- *
9
- * ## Wire protocol
10
- *
11
- * Browser → Proxy
12
- * ```
13
- * { type: "request", requestId, method, path, query, headers, body }
14
- * { type: "ping", id }
15
- * ```
16
- *
17
- * Proxy → Browser
18
- * ```
19
- * { type: "response-start", requestId, status, headers } (JSON string)
20
- * { type: "response-error", requestId, error: string } (JSON string)
21
- * { type: "pong", id } (JSON string)
22
- * ```
23
- *
24
- * Response bodies are sent as BINARY data-channel messages (not JSON), to
25
- * avoid the ~33% base64 overhead and the JSON encode/decode cost. Each binary
26
- * frame is laid out as:
27
- * ```
28
- * byte 0 flags (bit 0: done)
29
- * byte 1 idLen (length of the requestId in bytes)
30
- * bytes 2..2+N requestId (ASCII)
31
- * bytes 2+N.. payload (raw body bytes; empty on the final done frame)
32
- * ```
33
- * Control messages stay JSON strings so the browser can distinguish them from
34
- * body frames by message type (string vs ArrayBuffer).
35
- *
36
- * The protocol mirrors the tunnel relay protocol so both transports share
37
- * the same mental model and the same browser-side `WebRtcProxy` implementation.
38
- */
39
-
40
- /** @import { DataChannel } from 'node-datachannel' */
41
-
42
- /**
43
- * Configuration for the data channel handler.
44
- *
45
- * @typedef {Object} DataChannelHandlerOptions
46
- * @property {number} proxyPort
47
- * Local port the proxy's Fastify HTTP server is listening on.
48
- * Incoming requests are forwarded to `http://127.0.0.1:{proxyPort}`.
49
- * @property {(message: string) => void} [onLog]
50
- * Optional log sink.
51
- */
52
-
53
- /**
54
- * An incoming request message received over the data channel.
55
- *
56
- * @typedef {Object} DataChannelRequest
57
- * @property {string} requestId
58
- * @property {string} method - HTTP method (GET, POST, …).
59
- * @property {string} path - Request path (e.g. "/api/sources").
60
- * @property {string} query - Raw query string without the leading "?".
61
- * @property {Record<string, string>} headers - Headers to forward.
62
- * @property {string | null} body - Request body string, or null.
63
- */
64
-
65
- /**
66
- * The object returned by {@link createDataChannelHandler}.
67
- *
68
- * @typedef {Object} DataChannelHandler
69
- * @property {(sessionId: string, channel: DataChannel) => void} handleChannel
70
- * Wire message handlers onto a freshly opened data channel.
71
- */
72
-
73
- /**
74
- * Create a handler for incoming WebRTC data channels.
75
- *
76
- * @param {DataChannelHandlerOptions} options
77
- * @returns {DataChannelHandler}
78
- */
79
- export function createDataChannelHandler({ proxyPort, onLog }) {
80
- /**
81
- * @param {string} message
82
- * @returns {void}
83
- */
84
- function log(message) {
85
- if (typeof onLog === "function") {
86
- onLog(message);
87
- }
88
- }
89
-
90
- /**
91
- * Wire up the `onMessage`, `onClosed`, and `onError` handlers for a channel.
92
- *
93
- * @param {string} sessionId
94
- * @param {DataChannel} channel
95
- * @returns {void}
96
- */
97
- function handleChannel(sessionId, channel) {
98
- const tag = sessionId.slice(0, 8);
99
- log(`[dc] Session ${tag}: channel open`);
100
-
101
- // Partial chunked-request bodies in flight on THIS channel, keyed by
102
- // requestId. Each entry buffers frames until the done frame, then runs the
103
- // assembled request through the same path as a single-message request.
104
- /** @type {Map<string, { meta: object, chunks: Buffer[], receivedBytes: number, bodyBytes: number, timer: ReturnType<typeof setTimeout> }>} */
105
- const partials = new Map();
106
-
107
- const dropPartial = (requestId) => {
108
- const entry = partials.get(requestId);
109
- if (entry) {
110
- clearTimeout(entry.timer);
111
- partials.delete(requestId);
112
- }
113
- };
114
-
115
- /**
116
- * Begin assembling a chunked request. Validates the path and size up front
117
- * so an invalid or oversized request never buffers a body.
118
- *
119
- * @param {any} message - The `request-start` control message.
120
- */
121
- const startPartialRequest = (message) => {
122
- const { requestId, method, path, query, headers, bodyBytes } = message ?? {};
123
- if (typeof requestId !== "string" || requestId.length === 0) {
124
- return;
125
- }
126
- if (!isValidRequestPath(path)) {
127
- send(channel, { type: "response-error", requestId, error: "Invalid request path." });
128
- return;
129
- }
130
- if (!Number.isInteger(bodyBytes) || bodyBytes < 0 || bodyBytes > PROXY_MAX_REQUEST_BODY_BYTES) {
131
- send(channel, { type: "response-error", requestId, error: "Request body too large." });
132
- return;
133
- }
134
- dropPartial(requestId); // replace any stale entry with the same id
135
- const timer = setTimeout(() => {
136
- const entry = partials.get(requestId);
137
- partials.delete(requestId);
138
- log(`[dc] Session ${tag}: dropped stale partial request ${requestId.slice(0, 8)} (${entry?.receivedBytes ?? 0}B)`);
139
- }, PARTIAL_REQUEST_TTL_MS);
140
- partials.set(requestId, {
141
- meta: { requestId, method, path, query, headers },
142
- chunks: [],
143
- receivedBytes: 0,
144
- bodyBytes,
145
- timer
146
- });
147
- };
148
-
149
- /**
150
- * Handle a binary body frame for a chunked request.
151
- * Layout: [flags(1)][idLen(1)][requestId(ASCII)][payload].
152
- *
153
- * @param {Buffer} buf
154
- */
155
- const handleBodyFrame = (buf) => {
156
- if (buf.length < 2) {
157
- return;
158
- }
159
- const flags = buf[0];
160
- const idLen = buf[1];
161
- if (buf.length < 2 + idLen) {
162
- return;
163
- }
164
- const requestId = buf.toString("ascii", 2, 2 + idLen);
165
- const entry = partials.get(requestId);
166
- if (!entry) {
167
- return; // stale / already-dropped / aborted
168
- }
169
- if (flags & 2) {
170
- // Aborted by the browser — drop silently, no reply.
171
- dropPartial(requestId);
172
- return;
173
- }
174
- if (buf.length > 2 + idLen) {
175
- const payload = buf.subarray(2 + idLen);
176
- entry.chunks.push(Buffer.from(payload));
177
- entry.receivedBytes += payload.length;
178
- }
179
- if (entry.receivedBytes > entry.bodyBytes || entry.receivedBytes > PROXY_MAX_REQUEST_BODY_BYTES) {
180
- dropPartial(requestId);
181
- send(channel, { type: "response-error", requestId, error: "Request body size mismatch." });
182
- return;
183
- }
184
- if (flags & 1) {
185
- // Done frame — assemble and execute.
186
- dropPartial(requestId);
187
- if (entry.receivedBytes !== entry.bodyBytes) {
188
- send(channel, { type: "response-error", requestId, error: "Request body size mismatch." });
189
- return;
190
- }
191
- const body = Buffer.concat(entry.chunks).toString("utf8");
192
- void handleRequest(channel, { ...entry.meta, body }, true).catch((error) => {
193
- log(`[dc] Session ${tag}: request error: ${error?.message ?? error}`);
194
- });
195
- }
196
- };
197
-
198
- channel.onMessage((raw) => {
199
- // Binary messages are chunked-request body frames; the proxy otherwise
200
- // only ever receives JSON strings, so the type discriminates cleanly.
201
- if (typeof raw !== "string") {
202
- handleBodyFrame(Buffer.isBuffer(raw) ? raw : Buffer.from(raw));
203
- return;
204
- }
205
-
206
- /** @type {DataChannelRequest | { type: string, id?: string }} */
207
- let message;
208
- try {
209
- message = JSON.parse(raw);
210
- } catch {
211
- return;
212
- }
213
-
214
- if (message.type === "request") {
215
- void handleRequest(channel, message).catch((error) => {
216
- log(`[dc] Session ${tag}: request error: ${error?.message ?? error}`);
217
- });
218
- return;
219
- }
220
-
221
- if (message.type === "request-start") {
222
- startPartialRequest(message);
223
- return;
224
- }
225
-
226
- if (message.type === "ping") {
227
- send(channel, { type: "pong", id: message.id });
228
- }
229
- });
230
-
231
- channel.onClosed(() => {
232
- for (const entry of partials.values()) {
233
- clearTimeout(entry.timer);
234
- }
235
- partials.clear();
236
- log(`[dc] Session ${tag}: channel closed`);
237
- });
238
-
239
- channel.onError((err) => {
240
- log(`[dc] Session ${tag}: channel error: ${err}`);
241
- });
242
- }
243
-
244
- /**
245
- * Fetch a resource from the local proxy HTTP server and stream the response
246
- * back to the browser over the data channel.
247
- *
248
- * The `Host` header is rewritten to `127.0.0.1:{proxyPort}` so that Fastify
249
- * routes the request correctly regardless of what the browser sent.
250
- *
251
- * @param {DataChannel} channel
252
- * @param {DataChannelRequest} req
253
- * @returns {Promise<void>}
254
- */
255
- async function handleRequest(channel, req, viaChunks = false) {
256
- const { requestId, method, path, query, headers: forwardedHeaders, body } = req;
257
-
258
- // Reject paths that are not absolute, contain traversal sequences, or
259
- // do not start with a known proxy route prefix. All valid browser-side
260
- // requests use /api/*, /stream, /transcode/*, /health, or /healthz.
261
- if (!isValidRequestPath(path)) {
262
- send(channel, { type: "response-error", requestId, error: "Invalid request path." });
263
- return;
264
- }
265
-
266
- const queryInfo = query ? `?${query}` : "";
267
- const bodyInfo =
268
- body != null && typeof body === "string" && body.length > 0
269
- ? ` body=${body.length} bytes${viaChunks ? " (chunked)" : ""}`
270
- : "";
271
- log(`[dc] ${method} ${path}${queryInfo}${bodyInfo}`);
272
-
273
- const targetUrl = `http://127.0.0.1:${proxyPort}${path}${query ? `?${query}` : ""}`;
274
- const requestHeaders = { ...(forwardedHeaders ?? {}), host: `127.0.0.1:${proxyPort}` };
275
-
276
- let response;
277
- // [net-debug] TEMPORARY: time spent in the local fetch (waiting for the
278
- // route to return a response — e.g. long-polling until an HLS segment is
279
- // finalized by ffmpeg) vs. the body transfer over the data channel.
280
- const fetchStartedAt = Date.now();
281
- try {
282
- response = await fetch(targetUrl, {
283
- method,
284
- headers: requestHeaders,
285
- body: body != null ? body : undefined,
286
- redirect: "manual"
287
- });
288
- } catch (fetchError) {
289
- log(`[dc] ${method} ${path}${queryInfo} → error: ${fetchError?.message ?? String(fetchError)}`);
290
- send(channel, { type: "response-error", requestId, error: fetchError?.message ?? String(fetchError) });
291
- return;
292
- }
293
-
294
- if (response.status !== 200 && response.status !== 206) {
295
- log(`[dc] ${method} ${path}${queryInfo} → ${response.status}`);
296
- }
297
-
298
- /** @type {Record<string, string>} */
299
- const responseHeaders = {};
300
- for (const [name, value] of response.headers.entries()) {
301
- responseHeaders[name] = value;
302
- }
303
-
304
- send(channel, { type: "response-start", requestId, status: response.status, headers: responseHeaders });
305
-
306
- if (!response.body) {
307
- sendChunk(channel, requestId, null, true);
308
- return;
309
- }
310
-
311
- try {
312
- const reader = response.body.getReader();
313
- // [net-debug] TEMPORARY: measure transfer size/time and channel buffering.
314
- // fetchMs = time waiting for the route (incl. ffmpeg segment finalization).
315
- // ttfbMs = time from body-read start to the first chunk with data (loopback).
316
- // sendMs = total body read+send duration over the data channel.
317
- const fetchMs = Date.now() - fetchStartedAt;
318
- const sendStartedAt = Date.now();
319
- let firstByteMs = -1;
320
- let chunks = 0;
321
- let totalBytes = 0;
322
- let maxBuffered = 0;
323
- while (true) {
324
- const { done, value } = await reader.read();
325
- if (done) {
326
- sendChunk(channel, requestId, null, true);
327
- const elapsedMs = Date.now() - sendStartedAt;
328
- let bufferedNow = 0;
329
- try { bufferedNow = typeof channel.bufferedAmount === "function" ? channel.bufferedAmount() : 0; } catch { /* ignore */ }
330
- log(
331
- `[net-debug] sent ${path}${queryInfo} bytes=${totalBytes} fetchMs=${fetchMs} ` +
332
- `ttfbMs=${firstByteMs} sendMs=${elapsedMs} chunks=${chunks} ` +
333
- `maxBuffered=${maxBuffered} bufferedAtEnd=${bufferedNow}`
334
- );
335
- break;
336
- }
337
- if (firstByteMs < 0) firstByteMs = Date.now() - sendStartedAt;
338
- chunks += 1;
339
- totalBytes += value.length;
340
- try {
341
- const b = typeof channel.bufferedAmount === "function" ? channel.bufferedAmount() : 0;
342
- if (b > maxBuffered) maxBuffered = b;
343
- } catch { /* ignore */ }
344
- sendChunk(channel, requestId, value, false);
345
- // Backpressure: do not keep queuing chunks once the channel's outgoing
346
- // buffer is large wait for it to drain. Prevents the SCTP send buffer
347
- // from ballooning, which stalls throughput.
348
- await waitForBufferDrain(channel);
349
- }
350
- } catch {
351
- sendChunk(channel, requestId, null, true);
352
- }
353
- }
354
-
355
- /**
356
- * Send a response body frame as a BINARY data-channel message.
357
- * Layout: [flags(1)][idLen(1)][requestId(ASCII)][payload].
358
- *
359
- * @param {DataChannel} channel
360
- * @param {string} requestId
361
- * @param {Uint8Array | null} bytes - Body bytes, or null/empty for the done frame.
362
- * @param {boolean} done
363
- * @returns {void}
364
- */
365
- function sendChunk(channel, requestId, bytes, done) {
366
- try {
367
- const idBuf = Buffer.from(requestId, "ascii");
368
- const header = Buffer.allocUnsafe(2 + idBuf.length);
369
- header[0] = done ? 1 : 0;
370
- header[1] = idBuf.length;
371
- idBuf.copy(header, 2);
372
- const frame =
373
- bytes && bytes.length > 0 ? Buffer.concat([header, Buffer.from(bytes)]) : header;
374
- channel.sendMessageBinary(frame);
375
- } catch {
376
- // Channel closed between check and send — safe to ignore.
377
- }
378
- }
379
-
380
- /**
381
- * Resolve once the channel's outgoing buffer has drained below the low-water
382
- * mark. No-op (resolves immediately) when the buffer is already small or the
383
- * channel does not expose buffer APIs. A timeout fallback guards against a
384
- * missed low-water event so the send loop can never deadlock.
385
- *
386
- * @param {DataChannel} channel
387
- * @returns {Promise<void>}
388
- */
389
- function waitForBufferDrain(channel) {
390
- return new Promise((resolve) => {
391
- try {
392
- if (typeof channel.bufferedAmount !== "function" || channel.bufferedAmount() <= DC_BUFFER_HIGH_WATER) {
393
- resolve();
394
- return;
395
- }
396
- let settled = false;
397
- const done = () => {
398
- if (settled) return;
399
- settled = true;
400
- resolve();
401
- };
402
- channel.setBufferedAmountLowThreshold(DC_BUFFER_LOW_WATER);
403
- channel.onBufferedAmountLow(done);
404
- // Guard against a race where the buffer drained between the check above
405
- // and registering the callback (the low-water event would never fire).
406
- if (channel.bufferedAmount() <= DC_BUFFER_LOW_WATER) {
407
- done();
408
- return;
409
- }
410
- setTimeout(done, DC_BUFFER_DRAIN_TIMEOUT_MS);
411
- } catch {
412
- resolve();
413
- }
414
- });
415
- }
416
-
417
- /**
418
- * Serialise `message` to JSON and send it over the data channel.
419
- * Errors are silently swallowed the channel may have closed between
420
- * the open check and the actual send.
421
- *
422
- * @param {DataChannel} channel
423
- * @param {object} message
424
- * @returns {void}
425
- */
426
- function send(channel, message) {
427
- try {
428
- channel.sendMessage(JSON.stringify(message));
429
- } catch {
430
- // Channel closed between check and send — safe to ignore.
431
- }
432
- }
433
-
434
- return { handleChannel };
435
- }
436
-
437
- /**
438
- * Allowed path prefixes for data-channel requests.
439
- * Only the known proxy API and streaming routes are accepted.
440
- */
441
- const PATH_ALLOWLIST_RE = /^(?:\/api\/|\/stream(?:$|\?)|\/?transcode\/|\/health(?:z)?(?:$|\?))/;
442
-
443
- /**
444
- * True when `path` is an absolute, traversal-free path on a known proxy route.
445
- * Shared by the single-message and chunked request entry points.
446
- *
447
- * @param {unknown} path
448
- * @returns {boolean}
449
- */
450
- function isValidRequestPath(path) {
451
- return (
452
- typeof path === "string" &&
453
- path.startsWith("/") &&
454
- !path.includes("..") &&
455
- PATH_ALLOWLIST_RE.test(path)
456
- );
457
- }
458
-
459
- /** Max assembled size of a chunked request body (guards proxy memory). */
460
- const PROXY_MAX_REQUEST_BODY_BYTES = 32 * 1024 * 1024;
461
- /** Drop an incomplete chunked body if no further frame arrives within this window. */
462
- const PARTIAL_REQUEST_TTL_MS = 60_000;
463
-
464
- /** Pause sending body chunks once the channel buffer exceeds this many bytes. */
465
- const DC_BUFFER_HIGH_WATER = 8 * 1024 * 1024;
466
- /** Resume sending once the channel buffer drains to this many bytes. */
467
- const DC_BUFFER_LOW_WATER = 1 * 1024 * 1024;
468
- /** Safety fallback so the send loop cannot deadlock on a missed drain event. */
469
- const DC_BUFFER_DRAIN_TIMEOUT_MS = 5000;
1
+ /**
2
+ * @file WebRTC data channel request handler (proxy side).
3
+ *
4
+ * When a browser opens a data channel to this proxy, this handler wires up
5
+ * message handlers that implement an HTTP-over-DataChannel protocol:
6
+ * each incoming `request` message triggers a local `fetch` to the Fastify
7
+ * server, and the response is streamed back as base64-encoded chunks.
8
+ *
9
+ * ## Wire protocol
10
+ *
11
+ * Browser → Proxy
12
+ * ```
13
+ * { type: "request", requestId, method, path, query, headers, body }
14
+ * { type: "ping", id }
15
+ * ```
16
+ *
17
+ * Proxy → Browser
18
+ * ```
19
+ * { type: "response-start", requestId, status, headers } (JSON string)
20
+ * { type: "response-error", requestId, error: string } (JSON string)
21
+ * { type: "pong", id } (JSON string)
22
+ * ```
23
+ *
24
+ * Response bodies are sent as BINARY data-channel messages (not JSON), to
25
+ * avoid the ~33% base64 overhead and the JSON encode/decode cost. Each binary
26
+ * frame is laid out as:
27
+ * ```
28
+ * byte 0 flags (bit 0: done)
29
+ * byte 1 idLen (length of the requestId in bytes)
30
+ * bytes 2..2+N requestId (ASCII)
31
+ * bytes 2+N.. payload (raw body bytes; empty on the final done frame)
32
+ * ```
33
+ * Control messages stay JSON strings so the browser can distinguish them from
34
+ * body frames by message type (string vs ArrayBuffer).
35
+ *
36
+ * The protocol mirrors the tunnel relay protocol so both transports share
37
+ * the same mental model and the same browser-side `WebRtcProxy` implementation.
38
+ */
39
+
40
+ /** @import { DataChannel } from 'node-datachannel' */
41
+
42
+ /**
43
+ * Configuration for the data channel handler.
44
+ *
45
+ * @typedef {Object} DataChannelHandlerOptions
46
+ * @property {number} proxyPort
47
+ * Local port the proxy's Fastify HTTP server is listening on.
48
+ * Incoming requests are forwarded to `http://127.0.0.1:{proxyPort}`.
49
+ * @property {(message: string) => void} [onLog]
50
+ * Optional log sink.
51
+ */
52
+
53
+ /**
54
+ * An incoming request message received over the data channel.
55
+ *
56
+ * @typedef {Object} DataChannelRequest
57
+ * @property {string} requestId
58
+ * @property {string} method - HTTP method (GET, POST, …).
59
+ * @property {string} path - Request path (e.g. "/api/sources").
60
+ * @property {string} query - Raw query string without the leading "?".
61
+ * @property {Record<string, string>} headers - Headers to forward.
62
+ * @property {string | null} body - Request body string, or null.
63
+ */
64
+
65
+ /**
66
+ * The object returned by {@link createDataChannelHandler}.
67
+ *
68
+ * @typedef {Object} DataChannelHandler
69
+ * @property {(sessionId: string, channel: DataChannel) => void} handleChannel
70
+ * Wire message handlers onto a freshly opened data channel.
71
+ */
72
+
73
+ /**
74
+ * Create a handler for incoming WebRTC data channels.
75
+ *
76
+ * @param {DataChannelHandlerOptions} options
77
+ * @returns {DataChannelHandler}
78
+ */
79
+ import { performance } from "node:perf_hooks";
80
+ import { eventLoopDelay, resetEventLoopDelay } from "../utils/perf.js";
81
+
82
+ export function createDataChannelHandler({ proxyPort, onLog }) {
83
+ /**
84
+ * @param {string} message
85
+ * @returns {void}
86
+ */
87
+ function log(message) {
88
+ if (typeof onLog === "function") {
89
+ onLog(message);
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Wire up the `onMessage`, `onClosed`, and `onError` handlers for a channel.
95
+ *
96
+ * @param {string} sessionId
97
+ * @param {DataChannel} channel
98
+ * @returns {void}
99
+ */
100
+ function handleChannel(sessionId, channel) {
101
+ const tag = sessionId.slice(0, 8);
102
+ log(`[dc] Session ${tag}: channel open`);
103
+
104
+ // Partial chunked-request bodies in flight on THIS channel, keyed by
105
+ // requestId. Each entry buffers frames until the done frame, then runs the
106
+ // assembled request through the same path as a single-message request.
107
+ /** @type {Map<string, { meta: object, chunks: Buffer[], receivedBytes: number, bodyBytes: number, timer: ReturnType<typeof setTimeout> }>} */
108
+ const partials = new Map();
109
+
110
+ const dropPartial = (requestId) => {
111
+ const entry = partials.get(requestId);
112
+ if (entry) {
113
+ clearTimeout(entry.timer);
114
+ partials.delete(requestId);
115
+ }
116
+ };
117
+
118
+ /**
119
+ * Begin assembling a chunked request. Validates the path and size up front
120
+ * so an invalid or oversized request never buffers a body.
121
+ *
122
+ * @param {any} message - The `request-start` control message.
123
+ */
124
+ const startPartialRequest = (message) => {
125
+ const { requestId, method, path, query, headers, bodyBytes } = message ?? {};
126
+ if (typeof requestId !== "string" || requestId.length === 0) {
127
+ return;
128
+ }
129
+ if (!isValidRequestPath(path)) {
130
+ send(channel, { type: "response-error", requestId, error: "Invalid request path." });
131
+ return;
132
+ }
133
+ if (!Number.isInteger(bodyBytes) || bodyBytes < 0 || bodyBytes > PROXY_MAX_REQUEST_BODY_BYTES) {
134
+ send(channel, { type: "response-error", requestId, error: "Request body too large." });
135
+ return;
136
+ }
137
+ dropPartial(requestId); // replace any stale entry with the same id
138
+ const timer = setTimeout(() => {
139
+ const entry = partials.get(requestId);
140
+ partials.delete(requestId);
141
+ log(`[dc] Session ${tag}: dropped stale partial request ${requestId.slice(0, 8)} (${entry?.receivedBytes ?? 0}B)`);
142
+ }, PARTIAL_REQUEST_TTL_MS);
143
+ partials.set(requestId, {
144
+ meta: { requestId, method, path, query, headers },
145
+ chunks: [],
146
+ receivedBytes: 0,
147
+ bodyBytes,
148
+ timer
149
+ });
150
+ };
151
+
152
+ /**
153
+ * Handle a binary body frame for a chunked request.
154
+ * Layout: [flags(1)][idLen(1)][requestId(ASCII)][payload].
155
+ *
156
+ * @param {Buffer} buf
157
+ */
158
+ const handleBodyFrame = (buf) => {
159
+ if (buf.length < 2) {
160
+ return;
161
+ }
162
+ const flags = buf[0];
163
+ const idLen = buf[1];
164
+ if (buf.length < 2 + idLen) {
165
+ return;
166
+ }
167
+ const requestId = buf.toString("ascii", 2, 2 + idLen);
168
+ const entry = partials.get(requestId);
169
+ if (!entry) {
170
+ return; // stale / already-dropped / aborted
171
+ }
172
+ if (flags & 2) {
173
+ // Aborted by the browser — drop silently, no reply.
174
+ dropPartial(requestId);
175
+ return;
176
+ }
177
+ if (buf.length > 2 + idLen) {
178
+ const payload = buf.subarray(2 + idLen);
179
+ entry.chunks.push(Buffer.from(payload));
180
+ entry.receivedBytes += payload.length;
181
+ }
182
+ if (entry.receivedBytes > entry.bodyBytes || entry.receivedBytes > PROXY_MAX_REQUEST_BODY_BYTES) {
183
+ dropPartial(requestId);
184
+ send(channel, { type: "response-error", requestId, error: "Request body size mismatch." });
185
+ return;
186
+ }
187
+ if (flags & 1) {
188
+ // Done frame assemble and execute.
189
+ dropPartial(requestId);
190
+ if (entry.receivedBytes !== entry.bodyBytes) {
191
+ send(channel, { type: "response-error", requestId, error: "Request body size mismatch." });
192
+ return;
193
+ }
194
+ const body = Buffer.concat(entry.chunks).toString("utf8");
195
+ void handleRequest(channel, { ...entry.meta, body }, true).catch((error) => {
196
+ log(`[dc] Session ${tag}: request error: ${error?.message ?? error}`);
197
+ });
198
+ }
199
+ };
200
+
201
+ channel.onMessage((raw) => {
202
+ // Binary messages are chunked-request body frames; the proxy otherwise
203
+ // only ever receives JSON strings, so the type discriminates cleanly.
204
+ if (typeof raw !== "string") {
205
+ handleBodyFrame(Buffer.isBuffer(raw) ? raw : Buffer.from(raw));
206
+ return;
207
+ }
208
+
209
+ /** @type {DataChannelRequest | { type: string, id?: string }} */
210
+ let message;
211
+ try {
212
+ message = JSON.parse(raw);
213
+ } catch {
214
+ return;
215
+ }
216
+
217
+ if (message.type === "request") {
218
+ void handleRequest(channel, message).catch((error) => {
219
+ log(`[dc] Session ${tag}: request error: ${error?.message ?? error}`);
220
+ });
221
+ return;
222
+ }
223
+
224
+ if (message.type === "request-start") {
225
+ startPartialRequest(message);
226
+ return;
227
+ }
228
+
229
+ if (message.type === "ping") {
230
+ send(channel, { type: "pong", id: message.id });
231
+ }
232
+ });
233
+
234
+ channel.onClosed(() => {
235
+ for (const entry of partials.values()) {
236
+ clearTimeout(entry.timer);
237
+ }
238
+ partials.clear();
239
+ log(`[dc] Session ${tag}: channel closed`);
240
+ });
241
+
242
+ channel.onError((err) => {
243
+ log(`[dc] Session ${tag}: channel error: ${err}`);
244
+ });
245
+ }
246
+
247
+ /**
248
+ * Fetch a resource from the local proxy HTTP server and stream the response
249
+ * back to the browser over the data channel.
250
+ *
251
+ * The `Host` header is rewritten to `127.0.0.1:{proxyPort}` so that Fastify
252
+ * routes the request correctly regardless of what the browser sent.
253
+ *
254
+ * @param {DataChannel} channel
255
+ * @param {DataChannelRequest} req
256
+ * @returns {Promise<void>}
257
+ */
258
+ async function handleRequest(channel, req, viaChunks = false) {
259
+ const { requestId, method, path, query, headers: forwardedHeaders, body } = req;
260
+
261
+ // Reject paths that are not absolute, contain traversal sequences, or
262
+ // do not start with a known proxy route prefix. All valid browser-side
263
+ // requests use /api/*, /stream, /transcode/*, /health, or /healthz.
264
+ if (!isValidRequestPath(path)) {
265
+ send(channel, { type: "response-error", requestId, error: "Invalid request path." });
266
+ return;
267
+ }
268
+
269
+ const queryInfo = query ? `?${query}` : "";
270
+ const bodyInfo =
271
+ body != null && typeof body === "string" && body.length > 0
272
+ ? ` body=${body.length} bytes${viaChunks ? " (chunked)" : ""}`
273
+ : "";
274
+ log(`[dc] ${method} ${path}${queryInfo}${bodyInfo}`);
275
+
276
+ const targetUrl = `http://127.0.0.1:${proxyPort}${path}${query ? `?${query}` : ""}`;
277
+ const requestHeaders = { ...(forwardedHeaders ?? {}), host: `127.0.0.1:${proxyPort}` };
278
+
279
+ let response;
280
+ // [net-debug] TEMPORARY: time spent in the local fetch (waiting for the
281
+ // route to return a response — e.g. long-polling until an HLS segment is
282
+ // finalized by ffmpeg) vs. the body transfer over the data channel.
283
+ const fetchStartedAt = Date.now();
284
+ try {
285
+ response = await fetch(targetUrl, {
286
+ method,
287
+ headers: requestHeaders,
288
+ body: body != null ? body : undefined,
289
+ redirect: "manual"
290
+ });
291
+ } catch (fetchError) {
292
+ log(`[dc] ${method} ${path}${queryInfo} → error: ${fetchError?.message ?? String(fetchError)}`);
293
+ send(channel, { type: "response-error", requestId, error: fetchError?.message ?? String(fetchError) });
294
+ return;
295
+ }
296
+
297
+ if (response.status !== 200 && response.status !== 206) {
298
+ log(`[dc] ${method} ${path}${queryInfo} → ${response.status}`);
299
+ }
300
+
301
+ /** @type {Record<string, string>} */
302
+ const responseHeaders = {};
303
+ for (const [name, value] of response.headers.entries()) {
304
+ responseHeaders[name] = value;
305
+ }
306
+
307
+ send(channel, { type: "response-start", requestId, status: response.status, headers: responseHeaders });
308
+
309
+ if (!response.body) {
310
+ sendChunk(channel, requestId, null, true);
311
+ return;
312
+ }
313
+
314
+ try {
315
+ const reader = response.body.getReader();
316
+ // [net-debug] TEMPORARY: measure transfer size/time and channel buffering.
317
+ // fetchMs = time waiting for the route (incl. ffmpeg segment finalization).
318
+ // ttfbMs = time from body-read start to the first chunk with data (loopback).
319
+ // sendMs = total body read+send duration over the data channel.
320
+ const fetchMs = Date.now() - fetchStartedAt;
321
+ const sendStartedAt = Date.now();
322
+ let firstByteMs = -1;
323
+ let chunks = 0;
324
+ let totalBytes = 0;
325
+ let maxBuffered = 0;
326
+ // Attribute the transfer to the step that actually consumes the time.
327
+ // Without this split a slow transfer is indistinguishable between "the
328
+ // source is slow", "the channel is slow" and "the event loop is blocked",
329
+ // which is exactly the argument a field seek left unresolved.
330
+ let readMs = 0;
331
+ let sendMs2 = 0;
332
+ let drainMs = 0;
333
+ resetEventLoopDelay();
334
+ while (true) {
335
+ const readStartedAt = performance.now();
336
+ const { done, value } = await reader.read();
337
+ readMs += performance.now() - readStartedAt;
338
+ if (done) {
339
+ sendChunk(channel, requestId, null, true);
340
+ const elapsedMs = Date.now() - sendStartedAt;
341
+ let bufferedNow = 0;
342
+ try { bufferedNow = typeof channel.bufferedAmount === "function" ? channel.bufferedAmount() : 0; } catch { /* ignore */ }
343
+ const loop = eventLoopDelay();
344
+ const mbps = elapsedMs > 0 ? (totalBytes * 8) / (elapsedMs * 1000) : 0;
345
+ log(
346
+ `[net-debug] sent ${path}${queryInfo} bytes=${totalBytes} fetchMs=${fetchMs} ` +
347
+ `ttfbMs=${firstByteMs} sendMs=${elapsedMs} chunks=${chunks} ` +
348
+ `maxBuffered=${maxBuffered} bufferedAtEnd=${bufferedNow} ` +
349
+ // Where the time went: reading the body from the local route,
350
+ // handing chunks to the channel, or waiting for its queue. Plus
351
+ // the event-loop delay over the same window — a large max here
352
+ // means the transfer was blocked by synchronous work, not by the
353
+ // network, and the three figures above will all look inflated.
354
+ `readMs=${readMs.toFixed(0)} chanMs=${sendMs2.toFixed(0)} drainMs=${drainMs.toFixed(0)} ` +
355
+ `loopMean=${loop.meanMs.toFixed(1)} loopP99=${loop.p99Ms.toFixed(1)} loopMax=${loop.maxMs.toFixed(1)} ` +
356
+ `rate=${mbps.toFixed(1)}Mbps`
357
+ );
358
+ break;
359
+ }
360
+ if (firstByteMs < 0) firstByteMs = Date.now() - sendStartedAt;
361
+ chunks += 1;
362
+ totalBytes += value.length;
363
+ try {
364
+ const b = typeof channel.bufferedAmount === "function" ? channel.bufferedAmount() : 0;
365
+ if (b > maxBuffered) maxBuffered = b;
366
+ } catch { /* ignore */ }
367
+ const sendStepAt = performance.now();
368
+ sendChunk(channel, requestId, value, false);
369
+ sendMs2 += performance.now() - sendStepAt;
370
+ // Backpressure: do not keep queuing chunks once the channel's outgoing
371
+ // buffer is large — wait for it to drain. Prevents the SCTP send buffer
372
+ // from ballooning, which stalls throughput.
373
+ const drainStepAt = performance.now();
374
+ await waitForBufferDrain(channel);
375
+ drainMs += performance.now() - drainStepAt;
376
+ }
377
+ } catch {
378
+ sendChunk(channel, requestId, null, true);
379
+ }
380
+ }
381
+
382
+ /**
383
+ * Send a response body frame as a BINARY data-channel message.
384
+ * Layout: [flags(1)][idLen(1)][requestId(ASCII)][payload].
385
+ *
386
+ * @param {DataChannel} channel
387
+ * @param {string} requestId
388
+ * @param {Uint8Array | null} bytes - Body bytes, or null/empty for the done frame.
389
+ * @param {boolean} done
390
+ * @returns {void}
391
+ */
392
+ function sendChunk(channel, requestId, bytes, done) {
393
+ try {
394
+ const idBuf = Buffer.from(requestId, "ascii");
395
+ const header = Buffer.allocUnsafe(2 + idBuf.length);
396
+ header[0] = done ? 1 : 0;
397
+ header[1] = idBuf.length;
398
+ idBuf.copy(header, 2);
399
+ const frame =
400
+ bytes && bytes.length > 0 ? Buffer.concat([header, Buffer.from(bytes)]) : header;
401
+ channel.sendMessageBinary(frame);
402
+ } catch {
403
+ // Channel closed between check and send — safe to ignore.
404
+ }
405
+ }
406
+
407
+ /**
408
+ * Resolve once the channel's outgoing buffer has drained below the low-water
409
+ * mark. No-op (resolves immediately) when the buffer is already small or the
410
+ * channel does not expose buffer APIs. A timeout fallback guards against a
411
+ * missed low-water event so the send loop can never deadlock.
412
+ *
413
+ * @param {DataChannel} channel
414
+ * @returns {Promise<void>}
415
+ */
416
+ function waitForBufferDrain(channel) {
417
+ return new Promise((resolve) => {
418
+ try {
419
+ if (typeof channel.bufferedAmount !== "function" || channel.bufferedAmount() <= DC_BUFFER_HIGH_WATER) {
420
+ resolve();
421
+ return;
422
+ }
423
+ let settled = false;
424
+ const done = () => {
425
+ if (settled) return;
426
+ settled = true;
427
+ resolve();
428
+ };
429
+ channel.setBufferedAmountLowThreshold(DC_BUFFER_LOW_WATER);
430
+ channel.onBufferedAmountLow(done);
431
+ // Guard against a race where the buffer drained between the check above
432
+ // and registering the callback (the low-water event would never fire).
433
+ if (channel.bufferedAmount() <= DC_BUFFER_LOW_WATER) {
434
+ done();
435
+ return;
436
+ }
437
+ setTimeout(done, DC_BUFFER_DRAIN_TIMEOUT_MS);
438
+ } catch {
439
+ resolve();
440
+ }
441
+ });
442
+ }
443
+
444
+ /**
445
+ * Serialise `message` to JSON and send it over the data channel.
446
+ * Errors are silently swallowed — the channel may have closed between
447
+ * the open check and the actual send.
448
+ *
449
+ * @param {DataChannel} channel
450
+ * @param {object} message
451
+ * @returns {void}
452
+ */
453
+ function send(channel, message) {
454
+ try {
455
+ channel.sendMessage(JSON.stringify(message));
456
+ } catch {
457
+ // Channel closed between check and send — safe to ignore.
458
+ }
459
+ }
460
+
461
+ return { handleChannel };
462
+ }
463
+
464
+ /**
465
+ * Allowed path prefixes for data-channel requests.
466
+ * Only the known proxy API and streaming routes are accepted.
467
+ */
468
+ const PATH_ALLOWLIST_RE = /^(?:\/api\/|\/stream(?:$|\?)|\/?transcode\/|\/health(?:z)?(?:$|\?))/;
469
+
470
+ /**
471
+ * True when `path` is an absolute, traversal-free path on a known proxy route.
472
+ * Shared by the single-message and chunked request entry points.
473
+ *
474
+ * @param {unknown} path
475
+ * @returns {boolean}
476
+ */
477
+ function isValidRequestPath(path) {
478
+ return (
479
+ typeof path === "string" &&
480
+ path.startsWith("/") &&
481
+ !path.includes("..") &&
482
+ PATH_ALLOWLIST_RE.test(path)
483
+ );
484
+ }
485
+
486
+ /** Max assembled size of a chunked request body (guards proxy memory). */
487
+ const PROXY_MAX_REQUEST_BODY_BYTES = 32 * 1024 * 1024;
488
+ /** Drop an incomplete chunked body if no further frame arrives within this window. */
489
+ const PARTIAL_REQUEST_TTL_MS = 60_000;
490
+
491
+ /** Pause sending body chunks once the channel buffer exceeds this many bytes. */
492
+ const DC_BUFFER_HIGH_WATER = 8 * 1024 * 1024;
493
+ /** Resume sending once the channel buffer drains to this many bytes. */
494
+ const DC_BUFFER_LOW_WATER = 1 * 1024 * 1024;
495
+ /** Safety fallback so the send loop cannot deadlock on a missed drain event. */
496
+ const DC_BUFFER_DRAIN_TIMEOUT_MS = 5000;
@@ -84,7 +84,6 @@ const ENCODER_STALL_MS = 12_000;
84
84
  // encoding 125 s of content before reaching the viewer's position. Field
85
85
  // 2026-08-02: a seek took 56 s, of which ~50 s was this backoff.
86
86
  const SEEK_BACKOFF_SEGMENTS = 1;
87
- const SEEK_PULL_LIMIT_SEGMENTS = 120;
88
87
  const SEEK_SETTLE_MS = 1_200;
89
88
  // Hard cap on the total settle wait, measured from the first far request of a
90
89
  // burst, so a still-moving scrubber cannot delay a genuine seek forever.
@@ -1107,9 +1106,6 @@ export class HlsSessionManager {
1107
1106
  // #wireEncodeProcess and MAX_SEEK_FAILURES.
1108
1107
  seekFailureTarget: -1,
1109
1108
  seekFailureCount: 0,
1110
- // Lowest segment index the player is currently waiting for; -1 when
1111
- // nothing is pending. See getFileStream and #fireSettledSeek.
1112
- lowestAwaitedIndex: -1,
1113
1109
  progress: {
1114
1110
  state: "starting",
1115
1111
  processedSeconds: 0,
@@ -2139,10 +2135,6 @@ export class HlsSessionManager {
2139
2135
  // player needs a segment containing the preceding keyframe, so one that
2140
2136
  // begins exactly at the target is useless to it.
2141
2137
  const startIndex = Math.max(0, index - SEEK_BACKOFF_SEGMENTS);
2142
- // A new seek invalidates everything the player was waiting for before it:
2143
- // those requests describe where it USED to be. Clearing here is what keeps
2144
- // the pull below anchored to this seek.
2145
- session.lowestAwaitedIndex = -1;
2146
2138
  logger.info(
2147
2139
  `transcode ${session.id} viewer seek to ${positionSeconds.toFixed(1)}s → segment #${index}, ` +
2148
2140
  `starting at #${startIndex} (${SEEK_BACKOFF_SEGMENTS} back for the preceding keyframe)`
@@ -2244,20 +2236,23 @@ export class HlsSessionManager {
2244
2236
  : producedThisRun >= this.segmentDurationSec
2245
2237
  ? `run produced ${producedThisRun.toFixed(1)}s (first segment done)`
2246
2238
  : `grace of ${RUN_FIRST_SEGMENT_GRACE_MS / 1000}s expired`;
2247
- // Deliberately NOT pulled towards the lowest segment the player is waiting
2248
- // on. That was a stand-in for not knowing how far back the preceding
2249
- // keyframe lay, and it is now both unnecessary and harmful: boundaries are
2250
- // real keyframes (2.9.65), so exactly one segment back always suffices,
2251
- // while the player's outstanding requests at seek time still describe where
2252
- // it was PLAYING, not where it is going. Field 2026-08-02: a seek to #135
2253
- // was dragged back to #82 the position the viewer had just left — because
2254
- // the player was still fetching around it.
2255
- const effectiveTarget = target;
2239
+ // The start is exactly what requestSeek computed one segment before the
2240
+ // viewer's position and nothing else may move it.
2241
+ //
2242
+ // An earlier version pulled it down to the lowest segment the player had
2243
+ // outstanding, guessing how far back the preceding keyframe lay. That guess
2244
+ // is unnecessary now (boundaries ARE keyframes since 2.9.65) and was
2245
+ // actively wrong: during a scrub the player loads from wherever the slider
2246
+ // paused on its way, so those requests describe INTERMEDIATE positions, not
2247
+ // the destination. Measured 2026-08-02: dragging from 0 to 23:34 paused at
2248
+ // 863.4 s, the player fetched #82 for it, and a seek correctly resolved to
2249
+ // #134 was dragged back to #82. The browser's 300 ms debounce exists to
2250
+ // discard those intermediate positions — reading them back off the request
2251
+ // stream defeated it.
2256
2252
  session.seekTarget = null;
2257
2253
  session.seekFirstFarAt = 0;
2258
- session.lowestAwaitedIndex = -1;
2259
- logger.info(`transcode ${session.id} seek settle → restart at segment #${effectiveTarget} (${allowedBecause})`);
2260
- void this.#startEncodeRun(session, effectiveTarget);
2254
+ logger.info(`transcode ${session.id} seek settle → restart at segment #${target} (${allowedBecause})`);
2255
+ void this.#startEncodeRun(session, target);
2261
2256
  }
2262
2257
 
2263
2258
  /**
@@ -2461,16 +2456,6 @@ export class HlsSessionManager {
2461
2456
  // at this position (server-side seeking). The caller long-polls.
2462
2457
  if (!isPlaylist) {
2463
2458
  const requestedIndex = this.segmentFormat.segmentIndexFromName(fileName);
2464
- // Remember the LOWEST segment currently being waited on. The player
2465
- // always fetches below the seek target (it needs the preceding keyframe),
2466
- // and by how much varies — 8 segments in one measured seek, 57 in
2467
- // another. This is that figure straight from the player, and
2468
- // #fireSettledSeek uses it to pull the encode start down when the fixed
2469
- // SEEK_BACKOFF_SEGMENTS floor is not deep enough. Reset whenever a run
2470
- // starts, so it only ever describes the pending seek.
2471
- if (requestedIndex >= 0 && (session.lowestAwaitedIndex < 0 || requestedIndex < session.lowestAwaitedIndex)) {
2472
- session.lowestAwaitedIndex = requestedIndex;
2473
- }
2474
2459
  this.#ensureEncodingFor(
2475
2460
  session,
2476
2461
  requestedIndex,
package/utils/perf.js ADDED
@@ -0,0 +1,121 @@
1
+ /**
2
+ * @file Runtime performance instrumentation.
3
+ *
4
+ * Exists because a field seek took minutes and every explanation offered for it
5
+ * — encoder too slow, torrent too slow, channel too slow, event loop starved —
6
+ * was a guess. The numbers that would have settled it were not being recorded.
7
+ * This records them.
8
+ *
9
+ * Two things are measured:
10
+ *
11
+ * - **Event loop delay** (`perf_hooks.monitorEventLoopDelay`). If synchronous
12
+ * work (torrent piece hashing, large buffer handling) blocks the loop, every
13
+ * read and every send waits behind it, and the symptom looks exactly like a
14
+ * slow network. The histogram distinguishes the two beyond argument.
15
+ * - **Named operation timings**, so a slow transfer can be attributed to the
16
+ * step that actually consumed the time rather than to the whole.
17
+ *
18
+ * Deliberately cheap: a histogram sampled every 20 ms, and plain arithmetic per
19
+ * operation. No trace files, no profiler — `--trace-events-enabled` or
20
+ * `--cpu-prof` remain available for a deeper look when these figures point
21
+ * somewhere specific.
22
+ */
23
+
24
+ import { monitorEventLoopDelay, performance } from "node:perf_hooks";
25
+
26
+ const NANOSECONDS_PER_MILLISECOND = 1e6;
27
+ // Sampling interval for the loop-delay histogram. 20 ms is fine enough to catch
28
+ // the stalls that matter (tens of ms and up) without measurable overhead.
29
+ const LOOP_SAMPLE_INTERVAL_MS = 20;
30
+
31
+ const loopDelay = monitorEventLoopDelay({ resolution: LOOP_SAMPLE_INTERVAL_MS });
32
+ loopDelay.enable();
33
+
34
+ /**
35
+ * Event-loop delay since the last {@link resetEventLoopDelay}, in milliseconds.
36
+ *
37
+ * `mean` is the everyday cost; `max` and `p99` are what a single blocking spell
38
+ * does to whatever was waiting. A transfer that looks network-bound but shows a
39
+ * large `max` here was not network-bound at all.
40
+ *
41
+ * @returns {{ meanMs: number, p99Ms: number, maxMs: number }}
42
+ */
43
+ export function eventLoopDelay() {
44
+ return {
45
+ meanMs: loopDelay.mean / NANOSECONDS_PER_MILLISECOND,
46
+ p99Ms: loopDelay.percentile(99) / NANOSECONDS_PER_MILLISECOND,
47
+ maxMs: loopDelay.max / NANOSECONDS_PER_MILLISECOND
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Start a fresh measurement window for the loop-delay histogram, so a reported
53
+ * figure describes one operation rather than the process's whole lifetime.
54
+ *
55
+ * @returns {void}
56
+ */
57
+ export function resetEventLoopDelay() {
58
+ loopDelay.reset();
59
+ }
60
+
61
+ /**
62
+ * Accumulates how long the parts of one operation took.
63
+ *
64
+ * Usage: `mark()` after each step; `summary()` renders `step=12.3ms` pairs in
65
+ * the order they were marked.
66
+ */
67
+ export class OperationTimer {
68
+ #startedAt;
69
+ #lastMarkAt;
70
+ #marks;
71
+
72
+ constructor() {
73
+ this.#startedAt = performance.now();
74
+ this.#lastMarkAt = this.#startedAt;
75
+ this.#marks = [];
76
+ }
77
+
78
+ /**
79
+ * Record the time since the previous mark under `name`.
80
+ *
81
+ * @param {string} name
82
+ * @returns {number} Milliseconds since the previous mark.
83
+ */
84
+ mark(name) {
85
+ const now = performance.now();
86
+ const elapsed = now - this.#lastMarkAt;
87
+ this.#lastMarkAt = now;
88
+ this.#marks.push([name, elapsed]);
89
+ return elapsed;
90
+ }
91
+
92
+ /**
93
+ * Add a figure measured elsewhere (a running total, a count) so it appears in
94
+ * the same line as the timings.
95
+ *
96
+ * @param {string} name
97
+ * @param {number} value
98
+ * @returns {void}
99
+ */
100
+ add(name, value) {
101
+ this.#marks.push([name, value]);
102
+ }
103
+
104
+ /**
105
+ * Total elapsed time since construction, in milliseconds.
106
+ *
107
+ * @returns {number}
108
+ */
109
+ totalMs() {
110
+ return performance.now() - this.#startedAt;
111
+ }
112
+
113
+ /**
114
+ * All marks as `name=12.3ms` pairs, in order.
115
+ *
116
+ * @returns {string}
117
+ */
118
+ summary() {
119
+ return this.#marks.map(([name, value]) => `${name}=${value.toFixed(1)}ms`).join(" ");
120
+ }
121
+ }