@torrent-tv/proxy 2.9.69 → 2.9.71
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 +10 -0
- package/package.json +1 -1
- package/server.js +8 -2
- package/services/data-channel-handler.js +496 -469
- package/services/torrent-worker/channel.js +222 -0
- package/services/torrent-worker/client.js +264 -0
- package/services/torrent-worker/pool-adapter.js +171 -0
- package/services/torrent-worker/protocol.js +103 -0
- package/services/torrent-worker/worker.js +255 -0
- package/utils/perf.js +121 -0
|
@@ -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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
*
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}
|
|
126
|
-
if (
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
*
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
if (
|
|
170
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
partials.
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
//
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
let
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
*
|
|
384
|
-
*
|
|
385
|
-
*
|
|
386
|
-
* @param {DataChannel}
|
|
387
|
-
* @
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
/**
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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;
|