@gjsify/http2 0.4.0 → 0.4.4

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/src/server.ts DELETED
@@ -1,1228 +0,0 @@
1
- // Reference: Node.js lib/internal/http2/compat.js, lib/_http_server.js
2
- // Reimplemented for GJS using Soup.Server (HTTP/2 transparently via ALPN when TLS is used)
3
- //
4
- // Phase 1 limitations (resolved in Phase 2):
5
- // - createServer() serves HTTP/1.1 only (Soup does not support h2c/cleartext HTTP/2)
6
- // - createSecureServer() negotiates h2 via ALPN automatically when TLS cert is set
7
- // - pushStream(), respondWithFD(), respondWithFile() are stubs
8
- // - stream IDs are always 1 (Soup internal)
9
- //
10
- // Phase 2 (this file, post-`@gjsify/http2-native`):
11
- // - respondWithFD() — fully wired through fs.read on the FD into Soup's chunked write path
12
- // - respondWithFile() — fully wired through fs.createReadStream
13
- // - pushStream() — accepts the call, allocates a stream-id via the
14
- // GjsifyHttp2.StreamIdAllocator, builds the PUSH_PROMISE
15
- // frame in-memory via GjsifyHttp2.FrameEncoder. Wire-level
16
- // delivery still requires raw nghttp2-on-socket access
17
- // that Soup does not expose — see STATUS.md "Open TODOs".
18
- // The callback IS invoked with a usable ServerHttp2Stream
19
- // so application code that fans out a "main + push" pair
20
- // observes a working API contract.
21
- // - stream IDs — sourced from the bridge allocator (server pushes use
22
- // even ids starting at 2, client requests still appear
23
- // as 1 via the Soup compat layer)
24
-
25
- import Soup from '@girs/soup-3.0';
26
- import Gio from '@girs/gio-2.0';
27
- import GLib from '@girs/glib-2.0';
28
- import { EventEmitter } from 'node:events';
29
- import { Readable, Writable } from 'node:stream';
30
- import { Buffer } from 'node:buffer';
31
- import { read as fsRead, createReadStream, statSync, openSync, closeSync } from 'node:fs';
32
- import { deferEmit, ensureMainLoop } from '@gjsify/utils';
33
- import { hasNativeHttp2, loadNativeHttp2 } from '@gjsify/http2-native';
34
- import { constants, getDefaultSettings, type Http2Settings } from './protocol.js';
35
-
36
- export type { Http2Settings };
37
-
38
- // ─── Http2ServerRequest ───────────────────────────────────────────────────────
39
-
40
- export class Http2ServerRequest extends Readable {
41
- method = 'GET';
42
- url = '/';
43
- headers: Record<string, string | string[]> = {};
44
- rawHeaders: string[] = [];
45
- authority = '';
46
- scheme = 'https';
47
- httpVersion = '2.0';
48
- httpVersionMajor = 2;
49
- httpVersionMinor = 0;
50
- complete = false;
51
- socket: any = null;
52
- trailers: Record<string, string> = {};
53
- rawTrailers: string[] = [];
54
-
55
- private _stream: ServerHttp2Stream | null = null;
56
- private _timeoutTimer: ReturnType<typeof setTimeout> | null = null;
57
-
58
- get stream(): ServerHttp2Stream | null { return this._stream; }
59
-
60
- // Called by Http2Server after stream is created
61
- _setStream(stream: ServerHttp2Stream): void {
62
- this._stream = stream;
63
- }
64
-
65
- constructor() {
66
- super();
67
- }
68
-
69
- _read(_size: number): void {}
70
-
71
- // 'close' means connection lost, not body-stream end
72
- protected _autoClose(): void {}
73
-
74
- _pushBody(body: Uint8Array | null): void {
75
- if (body && body.length > 0) {
76
- this.push(Buffer.from(body));
77
- }
78
- this.push(null);
79
- this.complete = true;
80
- if (this._timeoutTimer) {
81
- clearTimeout(this._timeoutTimer);
82
- this._timeoutTimer = null;
83
- }
84
- }
85
-
86
- setTimeout(msecs: number, callback?: () => void): this {
87
- if (this._timeoutTimer) {
88
- clearTimeout(this._timeoutTimer);
89
- this._timeoutTimer = null;
90
- }
91
- if (callback) this.once('timeout', callback);
92
- if (msecs > 0) {
93
- this._timeoutTimer = setTimeout(() => {
94
- this._timeoutTimer = null;
95
- this.emit('timeout');
96
- }, msecs);
97
- }
98
- return this;
99
- }
100
-
101
- destroy(error?: Error): this {
102
- if (this._timeoutTimer) {
103
- clearTimeout(this._timeoutTimer);
104
- this._timeoutTimer = null;
105
- }
106
- return super.destroy(error) as this;
107
- }
108
- }
109
-
110
- // ─── Http2ServerResponse ──────────────────────────────────────────────────────
111
-
112
- export class Http2ServerResponse extends Writable {
113
- statusCode = 200;
114
- statusMessage = '';
115
- headersSent = false;
116
- finished = false;
117
- sendDate = true;
118
-
119
- private _soupMsg: Soup.ServerMessage | null;
120
- private _headers: Map<string, string | string[]> = new Map();
121
- private _streaming = false;
122
- private _timeoutTimer: ReturnType<typeof setTimeout> | null = null;
123
- private _stream: ServerHttp2Stream | null = null;
124
- /** Detached responses (PUSH_PROMISE children) buffer their output. */
125
- private _detachedBody: Buffer[] | null = null;
126
-
127
- get stream(): ServerHttp2Stream | null { return this._stream; }
128
- get socket(): null { return null; }
129
- /** Whether this response is detached from a Soup connection (push streams). */
130
- get isDetached(): boolean { return this._soupMsg === null; }
131
- /** Buffered body bytes for detached (push) responses — null on regular responses. */
132
- get detachedBody(): Buffer | null {
133
- return this._detachedBody ? Buffer.concat(this._detachedBody) : null;
134
- }
135
-
136
- // Called by Http2Server after stream is created
137
- _setStream(stream: ServerHttp2Stream): void {
138
- this._stream = stream;
139
- }
140
-
141
- constructor(soupMsg: Soup.ServerMessage | null) {
142
- super();
143
- this._soupMsg = soupMsg;
144
- if (soupMsg === null) this._detachedBody = [];
145
- }
146
-
147
- setHeader(name: string, value: string | number | string[]): this {
148
- this._headers.set(name.toLowerCase(), typeof value === 'number' ? String(value) : value);
149
- return this;
150
- }
151
-
152
- getHeader(name: string): string | string[] | undefined {
153
- return this._headers.get(name.toLowerCase());
154
- }
155
-
156
- removeHeader(name: string): void {
157
- this._headers.delete(name.toLowerCase());
158
- }
159
-
160
- hasHeader(name: string): boolean {
161
- return this._headers.has(name.toLowerCase());
162
- }
163
-
164
- getHeaderNames(): string[] {
165
- return Array.from(this._headers.keys());
166
- }
167
-
168
- getHeaders(): Record<string, string | string[]> {
169
- const result: Record<string, string | string[]> = {};
170
- for (const [key, value] of this._headers) {
171
- result[key] = value;
172
- }
173
- return result;
174
- }
175
-
176
- appendHeader(name: string, value: string | string[]): this {
177
- const lower = name.toLowerCase();
178
- const existing = this._headers.get(lower);
179
- if (existing === undefined) {
180
- this._headers.set(lower, value);
181
- } else if (Array.isArray(existing)) {
182
- Array.isArray(value) ? existing.push(...value) : existing.push(value);
183
- } else {
184
- this._headers.set(lower, Array.isArray(value) ? [existing as string, ...value] : [existing as string, value]);
185
- }
186
- return this;
187
- }
188
-
189
- flushHeaders(): void {
190
- if (!this.headersSent) this.headersSent = true;
191
- }
192
-
193
- writeHead(statusCode: number, statusMessage?: string | Record<string, string | string[]>, headers?: Record<string, string | string[]>): this {
194
- this.statusCode = statusCode;
195
- if (typeof statusMessage === 'object') {
196
- headers = statusMessage;
197
- statusMessage = undefined;
198
- }
199
- if (typeof statusMessage === 'string') this.statusMessage = statusMessage;
200
- if (headers) {
201
- for (const [key, value] of Object.entries(headers)) {
202
- this.setHeader(key, value);
203
- }
204
- }
205
- return this;
206
- }
207
-
208
- // http2 session-API alias — extracts :status from headers map
209
- respond(headers: Record<string, string | string[] | number>, options?: { endStream?: boolean }): void {
210
- const status = Number(headers[':status'] ?? 200);
211
- const rest: Record<string, string | string[]> = {};
212
- for (const [k, v] of Object.entries(headers)) {
213
- if (k === ':status') continue;
214
- rest[k] = typeof v === 'number' ? String(v) : v;
215
- }
216
- this.writeHead(status, rest);
217
- if (options?.endStream) this.end();
218
- }
219
-
220
- writeContinue(callback?: () => void): void {
221
- if (callback) Promise.resolve().then(callback);
222
- }
223
-
224
- writeEarlyHints(_hints: Record<string, string | string[]>, callback?: () => void): void {
225
- if (callback) Promise.resolve().then(callback);
226
- }
227
-
228
- addTrailers(_headers: Record<string, string>): void {}
229
-
230
- setTimeout(msecs: number, callback?: () => void): this {
231
- if (this._timeoutTimer) {
232
- clearTimeout(this._timeoutTimer);
233
- this._timeoutTimer = null;
234
- }
235
- if (callback) this.once('timeout', callback);
236
- if (msecs > 0) {
237
- this._timeoutTimer = setTimeout(() => {
238
- this._timeoutTimer = null;
239
- this.emit('timeout');
240
- }, msecs);
241
- }
242
- return this;
243
- }
244
-
245
- private _startStreaming(): void {
246
- if (this._streaming) return;
247
- this._streaming = true;
248
- this.headersSent = true;
249
-
250
- if (this._timeoutTimer) {
251
- clearTimeout(this._timeoutTimer);
252
- this._timeoutTimer = null;
253
- }
254
-
255
- if (!this._soupMsg) return; // detached push response — no Soup wire
256
-
257
- this._soupMsg.set_status(this.statusCode, this.statusMessage || null);
258
- const responseHeaders = this._soupMsg.get_response_headers();
259
-
260
- if (this._headers.has('content-length')) {
261
- responseHeaders.set_encoding(Soup.Encoding.CONTENT_LENGTH);
262
- } else {
263
- responseHeaders.set_encoding(Soup.Encoding.CHUNKED);
264
- }
265
-
266
- for (const [key, value] of this._headers) {
267
- if (Array.isArray(value)) {
268
- for (const v of value) responseHeaders.append(key, v);
269
- } else {
270
- responseHeaders.replace(key, value as string);
271
- }
272
- }
273
- }
274
-
275
- _write(chunk: any, encoding: string, callback: (error?: Error | null) => void): void {
276
- const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding as BufferEncoding);
277
- this._startStreaming();
278
- if (this._soupMsg) {
279
- const responseBody = this._soupMsg.get_response_body();
280
- responseBody.append(new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength));
281
- this._soupMsg.unpause();
282
- } else if (this._detachedBody) {
283
- this._detachedBody.push(buf);
284
- }
285
- callback();
286
- }
287
-
288
- _final(callback: (error?: Error | null) => void): void {
289
- if (this._streaming) {
290
- if (this._soupMsg) {
291
- const responseBody = this._soupMsg.get_response_body();
292
- responseBody.complete();
293
- this._soupMsg.unpause();
294
- }
295
- } else {
296
- this._sendBatchResponse();
297
- }
298
- this.finished = true;
299
- callback();
300
- }
301
-
302
- private _sendBatchResponse(): void {
303
- if (this.headersSent) return;
304
- this.headersSent = true;
305
-
306
- if (this._timeoutTimer) {
307
- clearTimeout(this._timeoutTimer);
308
- this._timeoutTimer = null;
309
- }
310
-
311
- if (!this._soupMsg) return;
312
-
313
- this._soupMsg.set_status(this.statusCode, this.statusMessage || null);
314
- const responseHeaders = this._soupMsg.get_response_headers();
315
-
316
- for (const [key, value] of this._headers) {
317
- if (Array.isArray(value)) {
318
- for (const v of value) responseHeaders.append(key, v);
319
- } else {
320
- responseHeaders.replace(key, value as string);
321
- }
322
- }
323
-
324
- const contentType = (this._headers.get('content-type') as string) || 'text/plain';
325
- this._soupMsg.set_response(contentType, Soup.MemoryUse.COPY, new Uint8Array(0));
326
- }
327
-
328
- end(chunk?: unknown, encoding?: BufferEncoding | (() => void), callback?: () => void): this {
329
- if (typeof chunk === 'function') {
330
- callback = chunk as () => void;
331
- chunk = undefined;
332
- } else if (typeof encoding === 'function') {
333
- callback = encoding;
334
- encoding = undefined;
335
- }
336
- if (chunk != null) {
337
- this.write(chunk as string | Buffer, encoding as BufferEncoding);
338
- }
339
- super.end(callback);
340
- return this;
341
- }
342
-
343
- /**
344
- * respondWithFD — stream the contents of an open file descriptor as the
345
- * response body. Headers are sent once `statCheck()` (if provided) has
346
- * had a chance to mutate them; payload is read in 64 KiB chunks via
347
- * `fs.read()` and dispatched through the existing Soup chunked-write path.
348
- *
349
- * Reference: Node.js doc/api/http2.md § respondWithFD()
350
- */
351
- respondWithFD(
352
- fd: number | { fd: number },
353
- headers?: Record<string, string | string[] | number>,
354
- options?: { offset?: number; length?: number; statCheck?: (stat: any, headers: any, statOptions: any) => void },
355
- ): void {
356
- _respondFromFD(this, fd, headers, options ?? {}, /* closeFd */ false);
357
- }
358
-
359
- /**
360
- * respondWithFile — stream a regular file by path. Opens the file with
361
- * fs.openSync, runs the optional `statCheck()` callback so the user can
362
- * mutate headers based on stat results (last-modified, size, etag, …),
363
- * then delegates to the same FD-streaming path as `respondWithFD()`.
364
- *
365
- * Reference: Node.js doc/api/http2.md § respondWithFile()
366
- */
367
- respondWithFile(
368
- path: string,
369
- headers?: Record<string, string | string[] | number>,
370
- options?: {
371
- offset?: number;
372
- length?: number;
373
- statCheck?: (stat: any, headers: any, statOptions: any) => void;
374
- onError?: (err: Error) => void;
375
- },
376
- ): void {
377
- let fd: number;
378
- try {
379
- fd = openSync(path, 'r');
380
- } catch (err) {
381
- if (options?.onError) {
382
- options.onError(err as Error);
383
- return;
384
- }
385
- throw err;
386
- }
387
- _respondFromFD(this, fd, headers, options ?? {}, /* closeFd */ true);
388
- }
389
-
390
- /**
391
- * pushStream — request the server to push an additional resource on a
392
- * fresh server-initiated stream. The Vala/nghttp2 bridge allocates the
393
- * promised even stream-id and constructs the PUSH_PROMISE frame; wire-level
394
- * delivery requires raw nghttp2-on-socket access that Soup does not expose,
395
- * so the byte-frame is currently a no-op on the wire — but the bridge
396
- * allocator and frame builder are exercised end-to-end and the callback
397
- * receives a fully-usable `ServerHttp2Stream` whose `respond()` / `end()`
398
- * calls write into a synthetic in-memory stream observable from tests.
399
- *
400
- * See STATUS.md "Open TODOs" → "http2 PUSH_PROMISE wire delivery".
401
- */
402
- pushStream(
403
- headers: Record<string, string | string[] | number>,
404
- options:
405
- | { parent?: number; weight?: number; exclusive?: boolean }
406
- | ((err: Error | null, pushStream: ServerHttp2Stream, headers: Record<string, string | string[]>) => void),
407
- callback?: (err: Error | null, pushStream: ServerHttp2Stream, headers: Record<string, string | string[]>) => void,
408
- ): void {
409
- if (typeof options === 'function') {
410
- callback = options;
411
- options = {};
412
- }
413
- if (!callback) {
414
- // Match Node behaviour: missing callback raises ERR_INVALID_ARG_TYPE
415
- throw new TypeError('callback must be a function');
416
- }
417
- if (!this._stream) {
418
- callback(new Error('No associated stream'), null as unknown as ServerHttp2Stream, {});
419
- return;
420
- }
421
- this._stream.pushStream(headers, options, callback);
422
- }
423
-
424
- /**
425
- * createPushResponse — alternate API: create a child Http2ServerResponse
426
- * for the push without needing to bridge through ServerHttp2Stream. The
427
- * created response shares the parent's stream allocator + bridge.
428
- *
429
- * Reference: Node.js doc/api/http2.md § Http2ServerResponse#createPushResponse()
430
- */
431
- createPushResponse(
432
- headers: Record<string, string | string[] | number>,
433
- callback: (err: Error | null, res: Http2ServerResponse) => void,
434
- ): void {
435
- if (typeof callback !== 'function') {
436
- throw new TypeError('callback must be a function');
437
- }
438
- this.pushStream(headers, {}, (err, pushStream) => {
439
- if (err) {
440
- callback(err, null as unknown as Http2ServerResponse);
441
- return;
442
- }
443
- // The synthetic ServerHttp2Stream owns its own Http2ServerResponse
444
- // (created in ServerHttp2Stream.pushStream below) — extract it.
445
- const res = (pushStream as unknown as { _res?: Http2ServerResponse })._res;
446
- callback(null, res ?? (null as unknown as Http2ServerResponse));
447
- });
448
- }
449
- }
450
-
451
- // ─── ServerHttp2Stream ────────────────────────────────────────────────────────
452
- // Facade over Http2ServerResponse exposing the session/stream API.
453
- // Delegates all writes to the underlying response object.
454
-
455
- export class ServerHttp2Stream extends EventEmitter {
456
- readonly id: number;
457
- readonly pushAllowed: boolean;
458
- readonly sentHeaders: Record<string, string | string[]> = {};
459
-
460
- private _res: Http2ServerResponse;
461
- private _session: ServerHttp2Session | null;
462
- private _isPushedStream: boolean;
463
- /** Children pushed off this request stream (parent → array). */
464
- private _pushedChildren: ServerHttp2Stream[] = [];
465
- /** Cached PUSH_PROMISE frame bytes for inspection in tests. */
466
- private _pushPromiseFrame: Uint8Array | null = null;
467
- /** Push request headers (`:method`, `:path`, …). */
468
- private _pushRequestHeaders: Record<string, string | string[]> | null = null;
469
-
470
- get session(): ServerHttp2Session | null { return this._session; }
471
- get headersSent(): boolean { return this._res.headersSent; }
472
- get closed(): boolean { return this._res.writableEnded; }
473
- get destroyed(): boolean { return this._res.destroyed; }
474
- get pending(): boolean { return false; }
475
- get state(): number {
476
- return this.closed ? constants.NGHTTP2_STREAM_STATE_CLOSED : constants.NGHTTP2_STREAM_STATE_OPEN;
477
- }
478
-
479
- /** Bytes of the PUSH_PROMISE frame this stream was reserved with (push streams only). */
480
- get pushPromiseFrame(): Uint8Array | null { return this._pushPromiseFrame; }
481
- /** Request headers the push was promised with (push streams only). */
482
- get pushRequestHeaders(): Record<string, string | string[]> | null { return this._pushRequestHeaders; }
483
- /** Push streams created from this stream. */
484
- get pushedChildren(): ReadonlyArray<ServerHttp2Stream> { return this._pushedChildren; }
485
-
486
- constructor(
487
- res: Http2ServerResponse,
488
- session: ServerHttp2Session | null = null,
489
- options: { isPushedStream?: boolean; streamId?: number } = {},
490
- ) {
491
- super();
492
- this._res = res;
493
- this._session = session;
494
- this._isPushedStream = options.isPushedStream === true;
495
- // Client-initiated streams keep the legacy id of 1 (Soup compat layer
496
- // multiplexing is opaque). Pushed streams get an even id from the
497
- // bridge allocator owned by the session.
498
- this.id = options.streamId ?? 1;
499
- // pushAllowed is set on REQUEST streams, indicating whether the peer
500
- // allows server pushes (SETTINGS_ENABLE_PUSH). Pushed streams never
501
- // allow further nesting (Node throws ERR_HTTP2_NESTED_PUSH).
502
- this.pushAllowed = !this._isPushedStream && session?.canPush !== false;
503
-
504
- res.on('finish', () => this.emit('close'));
505
- res.on('error', (err: Error) => this.emit('error', err));
506
- }
507
-
508
- // Session API: send response headers
509
- respond(headers: Record<string, string | string[] | number>, options?: { endStream?: boolean }): void {
510
- this._res.respond(headers, options);
511
- }
512
-
513
- // Writable-like interface delegating to response
514
- write(chunk: any, encoding?: BufferEncoding | (() => void), callback?: () => void): boolean {
515
- return this._res.write(chunk as any, encoding as any, callback as any);
516
- }
517
-
518
- end(chunk?: any, encoding?: BufferEncoding | (() => void), callback?: () => void): this {
519
- this._res.end(chunk as any, encoding as any, callback as any);
520
- return this;
521
- }
522
-
523
- destroy(error?: Error): this {
524
- this._res.destroy(error);
525
- return this;
526
- }
527
-
528
- close(code?: number, callback?: () => void): void {
529
- if (callback) this.once('close', callback);
530
- this._res.end();
531
- }
532
-
533
- priority(_options: { exclusive?: boolean; parent?: number; weight?: number; silent?: boolean }): void {}
534
-
535
- setTimeout(msecs: number, callback?: () => void): this {
536
- this._res.setTimeout(msecs, callback);
537
- return this;
538
- }
539
-
540
- sendTrailers(_headers: Record<string, string | string[]>): void {}
541
- additionalHeaders(_headers: Record<string, string | string[]>): void {}
542
-
543
- /** See {@link Http2ServerResponse.respondWithFD}. */
544
- respondWithFD(
545
- fd: number | { fd: number },
546
- headers?: Record<string, string | string[] | number>,
547
- options?: { offset?: number; length?: number; statCheck?: (stat: any, headers: any, statOptions: any) => void },
548
- ): void {
549
- this._res.respondWithFD(fd, headers, options);
550
- }
551
-
552
- /** See {@link Http2ServerResponse.respondWithFile}. */
553
- respondWithFile(
554
- path: string,
555
- headers?: Record<string, string | string[] | number>,
556
- options?: {
557
- offset?: number;
558
- length?: number;
559
- statCheck?: (stat: any, headers: any, statOptions: any) => void;
560
- onError?: (err: Error) => void;
561
- },
562
- ): void {
563
- this._res.respondWithFile(path, headers, options);
564
- }
565
-
566
- /**
567
- * pushStream — see {@link Http2ServerResponse.pushStream} for the full
568
- * contract. This is the lower-level entry point: it allocates a promised
569
- * stream-id from the session-bound `GjsifyHttp2.StreamIdAllocator`, builds
570
- * the PUSH_PROMISE frame via `GjsifyHttp2.FrameEncoder`, then synthesises
571
- * a child `ServerHttp2Stream` whose response surface is independent of
572
- * the parent's underlying SoupServerMessage.
573
- */
574
- pushStream(
575
- headers: Record<string, string | string[] | number>,
576
- options:
577
- | { parent?: number; weight?: number; exclusive?: boolean }
578
- | ((err: Error | null, pushStream: ServerHttp2Stream, headers: Record<string, string | string[]>) => void),
579
- callback?: (err: Error | null, pushStream: ServerHttp2Stream, headers: Record<string, string | string[]>) => void,
580
- ): void {
581
- if (typeof options === 'function') {
582
- callback = options;
583
- options = {};
584
- }
585
- if (!callback) {
586
- throw new TypeError('callback must be a function');
587
- }
588
-
589
- // Per RFC 7540 §8.2: pushed streams MUST NOT initiate further pushes.
590
- // Node surfaces this as ERR_HTTP2_NESTED_PUSH.
591
- if (this._isPushedStream) {
592
- const err = Object.assign(new Error('Cannot initiate nested push streams'), {
593
- code: 'ERR_HTTP2_NESTED_PUSH',
594
- });
595
- callback(err, null as unknown as ServerHttp2Stream, {});
596
- return;
597
- }
598
-
599
- // Session-level enable_push must be honoured. Soup-backed sessions
600
- // default to allowing it (we simulate the API), but a goaway/SETTINGS
601
- // toggle disables further pushes.
602
- if (this._session && this._session.canPush === false) {
603
- const err = Object.assign(new Error('HTTP/2 server push has been disabled'), {
604
- code: 'ERR_HTTP2_PUSH_DISABLED',
605
- });
606
- callback(err, null as unknown as ServerHttp2Stream, {});
607
- return;
608
- }
609
-
610
- // Allocate the promised stream-id and build the PUSH_PROMISE frame
611
- // bytes. Both go through the @gjsify/http2-native bridge when the
612
- // typelib is loadable; otherwise we fall back to in-process counters.
613
- let promisedId: number;
614
- let frameBytes: Uint8Array | null = null;
615
- let pushHeaders: Record<string, string | string[]> = {};
616
-
617
- // Normalise pseudo-headers — Node fills in :scheme/:authority from
618
- // the parent if omitted (matches refs/node/lib/internal/http2/util.js).
619
- const normalised: Record<string, string | string[]> = {};
620
- for (const [k, v] of Object.entries(headers)) {
621
- normalised[k] = typeof v === 'number' ? String(v) : v;
622
- }
623
- if (!normalised[':method']) normalised[':method'] = 'GET';
624
- pushHeaders = normalised;
625
-
626
- if (this._session) {
627
- promisedId = this._session._allocatePushId();
628
- if (promisedId === 0) {
629
- const err = Object.assign(new Error('No available stream ids'), {
630
- code: 'ERR_HTTP2_OUT_OF_STREAMS',
631
- });
632
- callback(err, null as unknown as ServerHttp2Stream, {});
633
- return;
634
- }
635
- frameBytes = this._session._buildPushPromise(this.id, promisedId, normalised);
636
- } else {
637
- // No session attached — synthesise a counter so tests see a stable id.
638
- promisedId = 2;
639
- }
640
-
641
- // Build the synthetic response surface. We can't dispatch a separate
642
- // SoupServerMessage onto the existing Soup connection (Soup multiplexes
643
- // streams internally and refuses external injection), so the push
644
- // response writes into a detached buffer reachable from `pushStream._res`.
645
- const pushRes = new Http2ServerResponse(_makeDetachedSoupMessage());
646
- const pushStream = new ServerHttp2Stream(pushRes, this._session, {
647
- isPushedStream: true,
648
- streamId: promisedId,
649
- });
650
- pushStream._pushPromiseFrame = frameBytes;
651
- pushStream._pushRequestHeaders = normalised;
652
- pushRes._setStream(pushStream);
653
- this._pushedChildren.push(pushStream);
654
-
655
- // Match Node's contract: callback runs asynchronously after the
656
- // pushStream is wired up.
657
- Promise.resolve().then(() => {
658
- callback!(null, pushStream, pushHeaders);
659
- });
660
- }
661
- }
662
-
663
- // ─── ServerHttp2Session ───────────────────────────────────────────────────────
664
-
665
- export class ServerHttp2Session extends EventEmitter {
666
- readonly type = constants.NGHTTP2_SESSION_SERVER;
667
- readonly alpnProtocol: string | undefined = 'h2';
668
- readonly encrypted: boolean = true;
669
-
670
- private _closed = false;
671
- private _destroyed = false;
672
- private _settings: Http2Settings;
673
- private _canPush = true;
674
- /** Lazy-initialised native bridge handles. */
675
- private _frameEncoder: ReturnType<NonNullable<ReturnType<typeof loadNativeHttp2>>['FrameEncoder']['new']> | null = null;
676
- private _streamIdAllocator: ReturnType<NonNullable<ReturnType<typeof loadNativeHttp2>>['StreamIdAllocator']['new']> | null = null;
677
- /** Fallback id counter used when the native bridge is unavailable. */
678
- private _fallbackPushId = 2;
679
-
680
- constructor() {
681
- super();
682
- this._settings = getDefaultSettings();
683
- }
684
-
685
- /** Whether server-push is currently permitted on this session. */
686
- get canPush(): boolean { return this._canPush; }
687
- set canPush(v: boolean) { this._canPush = v; }
688
-
689
- /** @internal Allocate the next promised (even) stream id for a push. */
690
- _allocatePushId(): number {
691
- const native = loadNativeHttp2();
692
- if (native) {
693
- if (!this._streamIdAllocator) {
694
- this._streamIdAllocator = native.StreamIdAllocator.new();
695
- }
696
- return this._streamIdAllocator.next_promised();
697
- }
698
- const id = this._fallbackPushId;
699
- if (id > 0x7fffffff) return 0;
700
- this._fallbackPushId += 2;
701
- return id;
702
- }
703
-
704
- /** @internal Build PUSH_PROMISE frame bytes via the native bridge (or null when unavailable). */
705
- _buildPushPromise(
706
- associatedStreamId: number,
707
- promisedStreamId: number,
708
- headers: Record<string, string | string[]>,
709
- ): Uint8Array | null {
710
- const native = loadNativeHttp2();
711
- if (!native) return null;
712
- if (!this._frameEncoder) this._frameEncoder = native.FrameEncoder.new();
713
-
714
- // HPACK encodes a flat names/values pair list. HTTP/2 requires lower
715
- // case names; we coerce here so callers don't have to remember.
716
- const names: string[] = [];
717
- const values: string[] = [];
718
- for (const [k, v] of Object.entries(headers)) {
719
- const name = k.toLowerCase();
720
- if (Array.isArray(v)) {
721
- for (const item of v) {
722
- names.push(name);
723
- values.push(String(item));
724
- }
725
- } else {
726
- names.push(name);
727
- values.push(String(v));
728
- }
729
- }
730
-
731
- const block = this._frameEncoder.encode_headers(names, values);
732
- if (!block) return null;
733
- const frame = this._frameEncoder.build_push_promise(associatedStreamId, promisedStreamId, block);
734
- // GLib.Bytes.toArray() yields a Uint8Array snapshot.
735
- const arr = (frame as unknown as { toArray?: () => Uint8Array }).toArray;
736
- if (typeof arr === 'function') return arr.call(frame);
737
- // GJS sometimes returns the bytes as a structured object — use get_data()
738
- const getData = (frame as unknown as { get_data?: () => Uint8Array | null }).get_data;
739
- if (typeof getData === 'function') {
740
- const d = getData.call(frame);
741
- return d ?? null;
742
- }
743
- return null;
744
- }
745
-
746
- get closed(): boolean { return this._closed; }
747
- get destroyed(): boolean { return this._destroyed; }
748
- get pendingSettingsAck(): boolean { return false; }
749
- get localSettings(): Http2Settings { return { ...this._settings }; }
750
- get remoteSettings(): Http2Settings { return getDefaultSettings(); }
751
- get originSet(): string[] { return []; }
752
-
753
- settings(settings: Http2Settings, callback?: () => void): void {
754
- Object.assign(this._settings, settings);
755
- if (callback) Promise.resolve().then(callback);
756
- }
757
-
758
- goaway(code?: number, _lastStreamId?: number, _data?: Uint8Array): void {
759
- this.emit('goaway', code ?? constants.NGHTTP2_NO_ERROR);
760
- this.destroy();
761
- }
762
-
763
- ping(_payload?: Uint8Array, callback?: (err: Error | null, duration: number, payload: Uint8Array) => void): boolean {
764
- const buf = new Uint8Array(8);
765
- if (callback) Promise.resolve().then(() => callback(null, 0, buf));
766
- return true;
767
- }
768
-
769
- close(callback?: () => void): void {
770
- if (this._closed) return;
771
- this._closed = true;
772
- this.emit('close');
773
- if (callback) callback();
774
- }
775
-
776
- destroy(error?: Error, code?: number): void {
777
- if (this._destroyed) return;
778
- this._destroyed = true;
779
- this._closed = true;
780
- if (error) this.emit('error', error);
781
- if (code !== undefined) this.emit('goaway', code);
782
- this.emit('close');
783
- }
784
-
785
- altsvc(_alt: string, _originOrStream: string | number): void {}
786
- origin(..._origins: string[]): void {}
787
- ref(): void {}
788
- unref(): void {}
789
- }
790
-
791
- // ─── Http2Server ──────────────────────────────────────────────────────────────
792
-
793
- // GC guard — prevents server from being collected while listening
794
- const _activeServers = new Set<Http2Server>();
795
-
796
- export interface ServerOptions {
797
- allowHTTP1?: boolean;
798
- maxDeflateDynamicTableSize?: number;
799
- maxSessionMemory?: number;
800
- maxHeaderListPairs?: number;
801
- maxOutstandingPings?: number;
802
- maxSendHeaderBlockLength?: number;
803
- paddingStrategy?: number;
804
- peerMaxHeaderListSize?: number;
805
- selectPadding?: (frameLen: number, maxFrameLen: number) => number;
806
- settings?: Http2Settings;
807
- Http1IncomingMessage?: any;
808
- Http1ServerResponse?: any;
809
- unknownProtocolTimeout?: number;
810
- }
811
-
812
- export class Http2Server extends EventEmitter {
813
- listening = false;
814
- maxHeadersCount = 2000;
815
- timeout = 0;
816
-
817
- protected _soupServer: Soup.Server | null = null;
818
- protected _address: { port: number; family: string; address: string } | null = null;
819
- private _options: ServerOptions;
820
-
821
- get soupServer(): Soup.Server | null { return this._soupServer; }
822
-
823
- constructor(options?: ServerOptions | ((req: Http2ServerRequest, res: Http2ServerResponse) => void), handler?: (req: Http2ServerRequest, res: Http2ServerResponse) => void) {
824
- super();
825
- if (typeof options === 'function') {
826
- handler = options;
827
- options = {};
828
- }
829
- this._options = options ?? {};
830
- if (handler) this.on('request', handler);
831
- }
832
-
833
- listen(port?: number, hostname?: string, backlog?: number, callback?: () => void): this;
834
- listen(port?: number, hostname?: string, callback?: () => void): this;
835
- listen(port?: number, callback?: () => void): this;
836
- listen(...args: unknown[]): this {
837
- let port = 0;
838
- let hostname = '0.0.0.0';
839
- let callback: (() => void) | undefined;
840
-
841
- for (const arg of args) {
842
- if (typeof arg === 'number') port = arg;
843
- else if (typeof arg === 'string') hostname = arg;
844
- else if (typeof arg === 'function') callback = arg as () => void;
845
- }
846
-
847
- if (callback) this.once('listening', callback);
848
-
849
- try {
850
- this._soupServer = new Soup.Server({});
851
- this._configureSoupServer(this._soupServer);
852
-
853
- this._soupServer.add_handler(null, (_server: Soup.Server, msg: Soup.ServerMessage, _path: string) => {
854
- this._handleRequest(msg);
855
- });
856
-
857
- this._soupServer.listen_local(port, Soup.ServerListenOptions.IPV4_ONLY);
858
- ensureMainLoop();
859
-
860
- const listeners = this._soupServer.get_listeners();
861
- let actualPort = port;
862
- if (listeners && listeners.length > 0) {
863
- const addr = listeners[0].get_local_address() as Gio.InetSocketAddress;
864
- if (addr && typeof addr.get_port === 'function') {
865
- actualPort = addr.get_port();
866
- }
867
- }
868
-
869
- this.listening = true;
870
- this._address = { port: actualPort, family: 'IPv4', address: hostname };
871
- _activeServers.add(this);
872
-
873
- deferEmit(this, 'listening');
874
- } catch (err: unknown) {
875
- const error = err instanceof Error ? err : new Error(String(err));
876
- if (this.listenerCount('error') === 0) throw error;
877
- deferEmit(this, 'error', error);
878
- }
879
-
880
- return this;
881
- }
882
-
883
- // Override in Http2SecureServer to set TLS certificate before listen
884
- protected _configureSoupServer(_server: Soup.Server): void {}
885
-
886
- private _handleRequest(soupMsg: Soup.ServerMessage): void {
887
- const req = new Http2ServerRequest();
888
- const res = new Http2ServerResponse(soupMsg);
889
-
890
- // Populate request metadata
891
- req.method = soupMsg.get_method();
892
- const uri = soupMsg.get_uri();
893
- const path = uri.get_path();
894
- const query = uri.get_query();
895
- req.url = query ? path + '?' + query : path;
896
- req.authority = uri.get_host() ?? '';
897
- req.scheme = uri.get_scheme() ?? 'http';
898
-
899
- // Detect HTTP version from Soup
900
- const httpVersion = soupMsg.get_http_version();
901
- if (httpVersion === Soup.HTTPVersion.HTTP_2_0) {
902
- req.httpVersion = '2.0';
903
- req.httpVersionMajor = 2;
904
- req.httpVersionMinor = 0;
905
- } else {
906
- req.httpVersion = '1.1';
907
- req.httpVersionMajor = 1;
908
- req.httpVersionMinor = 1;
909
- }
910
-
911
- // Parse request headers
912
- const requestHeaders = soupMsg.get_request_headers();
913
- requestHeaders.foreach((name: string, value: string) => {
914
- const lower = name.toLowerCase();
915
- req.rawHeaders.push(name, value);
916
- if (lower in req.headers) {
917
- const existing = req.headers[lower];
918
- if (Array.isArray(existing)) {
919
- existing.push(value);
920
- } else {
921
- req.headers[lower] = [existing as string, value];
922
- }
923
- } else {
924
- req.headers[lower] = value;
925
- }
926
- });
927
-
928
- // Remote address info
929
- const remoteHost = soupMsg.get_remote_host() ?? '127.0.0.1';
930
- const remoteAddr = soupMsg.get_remote_address();
931
- const remotePort = (remoteAddr instanceof Gio.InetSocketAddress) ? remoteAddr.get_port() : 0;
932
- req.socket = {
933
- remoteAddress: remoteHost,
934
- remotePort,
935
- localAddress: this._address?.address ?? '127.0.0.1',
936
- localPort: this._address?.port ?? 0,
937
- encrypted: this instanceof Http2SecureServer,
938
- } as any;
939
-
940
- // Push request body into the readable stream
941
- const body = soupMsg.get_request_body();
942
- if (body?.data && body.data.length > 0) {
943
- req._pushBody(body.data);
944
- } else {
945
- req._pushBody(null);
946
- }
947
-
948
- // Build headers record for 'stream' event (http2 session API)
949
- const streamHeaders: Record<string, string | string[]> = {
950
- ':method': req.method,
951
- ':path': req.url,
952
- ':authority': req.authority,
953
- ':scheme': req.scheme,
954
- ...req.headers,
955
- };
956
-
957
- // Pause Soup until response is sent
958
- soupMsg.pause();
959
- res.on('finish', () => soupMsg.unpause());
960
-
961
- // Create stream facade and wire references
962
- const session = new ServerHttp2Session();
963
- const stream = new ServerHttp2Stream(res, session);
964
- req._setStream(stream);
965
- res._setStream(stream);
966
-
967
- // Emit both session API ('stream') and compat API ('request') events
968
- this.emit('stream', stream, streamHeaders);
969
- this.emit('request', req, res);
970
- }
971
-
972
- address(): { port: number; family: string; address: string } | null {
973
- return this._address;
974
- }
975
-
976
- close(callback?: (err?: Error) => void): this {
977
- if (callback) this.once('close', callback);
978
- if (this._soupServer) {
979
- this._soupServer.disconnect();
980
- this._soupServer = null;
981
- }
982
- this.listening = false;
983
- _activeServers.delete(this);
984
- deferEmit(this, 'close');
985
- return this;
986
- }
987
-
988
- setTimeout(msecs: number, callback?: () => void): this {
989
- this.timeout = msecs;
990
- if (callback) this.on('timeout', callback);
991
- return this;
992
- }
993
- }
994
-
995
- // ─── Http2SecureServer ────────────────────────────────────────────────────────
996
-
997
- export interface SecureServerOptions extends ServerOptions {
998
- cert?: string | Buffer | Array<string | Buffer>;
999
- key?: string | Buffer | Array<string | Buffer>;
1000
- pfx?: string | Buffer | Array<string | Buffer>;
1001
- passphrase?: string;
1002
- ca?: string | Buffer | Array<string | Buffer>;
1003
- requestCert?: boolean;
1004
- rejectUnauthorized?: boolean;
1005
- ALPNProtocols?: string[];
1006
- }
1007
-
1008
- export class Http2SecureServer extends Http2Server {
1009
- private _tlsCert: Gio.TlsCertificate | null = null;
1010
-
1011
- constructor(options: SecureServerOptions, handler?: (req: Http2ServerRequest, res: Http2ServerResponse) => void) {
1012
- super(options, handler);
1013
-
1014
- if (options.cert && options.key) {
1015
- const certPem = _toPemString(options.cert);
1016
- const keyPem = _toPemString(options.key);
1017
- this._tlsCert = _createTlsCertificate(certPem, keyPem);
1018
- } else if (options.pfx) {
1019
- // PKCS#12 not supported yet; TLS still works if a cert was set via setSecureContext
1020
- }
1021
- }
1022
-
1023
- protected _configureSoupServer(server: Soup.Server): void {
1024
- if (this._tlsCert) {
1025
- server.set_tls_certificate(this._tlsCert);
1026
- }
1027
- }
1028
-
1029
- setSecureContext(options: SecureServerOptions): void {
1030
- if (options.cert && options.key) {
1031
- const certPem = _toPemString(options.cert);
1032
- const keyPem = _toPemString(options.key);
1033
- this._tlsCert = _createTlsCertificate(certPem, keyPem);
1034
- if (this._soupServer && this._tlsCert) {
1035
- this._soupServer.set_tls_certificate(this._tlsCert);
1036
- }
1037
- }
1038
- }
1039
- }
1040
-
1041
- // ─── Helpers ──────────────────────────────────────────────────────────────────
1042
-
1043
- function _toPemString(value: string | Buffer | Array<string | Buffer>): string {
1044
- if (Array.isArray(value)) {
1045
- return value.map(_toPemString).join('\n');
1046
- }
1047
- return Buffer.isBuffer(value) ? value.toString('utf8') : (value as string);
1048
- }
1049
-
1050
- function _createTlsCertificate(certPem: string, keyPem: string): Gio.TlsCertificate {
1051
- // Combine cert + key into a single PEM string — Gio.TlsCertificate.new_from_pem() accepts both
1052
- const combined = certPem.trimEnd() + '\n' + keyPem.trimEnd() + '\n';
1053
- try {
1054
- return Gio.TlsCertificate.new_from_pem(combined, -1);
1055
- } catch (err) {
1056
- // Fall back: write to temp files
1057
- const tmpDir = GLib.get_tmp_dir();
1058
- const certPath = GLib.build_filenamev([tmpDir, 'gjsify-http2-cert.pem']);
1059
- const keyPath = GLib.build_filenamev([tmpDir, 'gjsify-http2-key.pem']);
1060
- try {
1061
- GLib.file_set_contents(certPath, certPem);
1062
- GLib.file_set_contents(keyPath, keyPem);
1063
- const tlsCert = Gio.TlsCertificate.new_from_files(certPath, keyPath);
1064
- return tlsCert;
1065
- } finally {
1066
- try { Gio.File.new_for_path(certPath).delete(null); } catch {}
1067
- try { Gio.File.new_for_path(keyPath).delete(null); } catch {}
1068
- }
1069
- }
1070
- }
1071
-
1072
- /**
1073
- * _makeDetachedSoupMessage — placeholder factory for push-stream Http2ServerResponse.
1074
- *
1075
- * Push streams have no associated SoupServerMessage (the Soup connection
1076
- * multiplexer multiplexes them internally and refuses external injection),
1077
- * so we hand the response a `null` Soup message and let it route writes
1078
- * into a buffered backing store via `Http2ServerResponse._detachedBody`.
1079
- *
1080
- * Kept as a function (not an inline `null`) so future revisions can return
1081
- * a real shadow message once Soup exposes the underlying nghttp2 session
1082
- * — call sites won't have to change.
1083
- */
1084
- function _makeDetachedSoupMessage(): Soup.ServerMessage | null {
1085
- return null;
1086
- }
1087
-
1088
- /**
1089
- * _respondFromFD — common implementation behind respondWithFD / respondWithFile.
1090
- *
1091
- * Flow:
1092
- * 1) statSync on the FD so the user-supplied `statCheck()` callback can
1093
- * mutate headers based on size / mtime / ino (Node parity).
1094
- * 2) flushHeaders via writeHead — kicks the Soup chunked-write path.
1095
- * 3) Read the FD in 64 KiB chunks via fs.read; pipe each chunk through
1096
- * `res.write()` so existing Soup pause/unpause back-pressure applies.
1097
- * 4) On EOF, call `res.end()` and close the FD if we opened it.
1098
- *
1099
- * This deliberately uses `node:fs` (the gjsify polyfill) instead of
1100
- * `Gio.UnixInputStream` so the same code path works on Node test runs.
1101
- */
1102
- function _respondFromFD(
1103
- res: Http2ServerResponse,
1104
- fdOrHandle: number | { fd: number },
1105
- headers: Record<string, string | string[] | number> | undefined,
1106
- options: { offset?: number; length?: number; statCheck?: (stat: any, headers: any, statOptions: any) => void; onError?: (err: Error) => void },
1107
- closeFd: boolean,
1108
- ): void {
1109
- // Both raw numeric fds and `@gjsify/fs` FileHandle wrappers (which carry
1110
- // the numeric fd on `.fd`) are accepted — `fs.openSync()` returns the
1111
- // wrapper on GJS, a raw integer on Node.
1112
- const fd: number = typeof fdOrHandle === 'number' ? fdOrHandle : (fdOrHandle as { fd: number }).fd;
1113
- // Always hand `fs.read` / `fs.close` the numeric fd. On GJS the @gjsify/fs
1114
- // FileHandle wrapper registers itself under the numeric fd in its FD
1115
- // table — passing the wrapper object itself fails the lookup
1116
- // (object → "[object Object]" string key).
1117
- const fdArg: number = fd;
1118
- const finalHeaders: Record<string, string | string[] | number> = { ...(headers ?? {}) };
1119
-
1120
- // statCheck — mirrors Node's contract: lets the app mutate headers based
1121
- // on stat results without hand-writing fstat boilerplate.
1122
- if (options.statCheck) {
1123
- try {
1124
- const stat = statSync(_fdPath(fd) ?? '/proc/self/fd/' + fd);
1125
- const cont = options.statCheck(stat, finalHeaders, options) as unknown;
1126
- if (cont === false) {
1127
- if (closeFd) closeSync(fd);
1128
- res.end();
1129
- return;
1130
- }
1131
- } catch (err) {
1132
- if (options.onError) {
1133
- options.onError(err as Error);
1134
- if (closeFd) closeSync(fd);
1135
- return;
1136
- }
1137
- // Continue without statCheck — Node's behaviour is to skip silently
1138
- // when fstat fails (the FD will fail later in the read loop anyway).
1139
- }
1140
- }
1141
-
1142
- // Headers go out first.
1143
- const status = Number(finalHeaders[':status'] ?? 200);
1144
- delete finalHeaders[':status'];
1145
- const sanitised: Record<string, string | string[]> = {};
1146
- for (const [k, v] of Object.entries(finalHeaders)) {
1147
- sanitised[k] = typeof v === 'number' ? String(v) : v;
1148
- }
1149
- res.writeHead(status, sanitised);
1150
- res.flushHeaders();
1151
-
1152
- const startOffset = Math.max(0, options.offset ?? 0);
1153
- const totalLength = options.length;
1154
- const CHUNK = 64 * 1024;
1155
- const buffer = Buffer.alloc(CHUNK);
1156
- let position = startOffset;
1157
- let remaining = typeof totalLength === 'number' ? totalLength : Infinity;
1158
- let bytesSent = 0;
1159
-
1160
- const readNext = (): void => {
1161
- if (remaining <= 0) {
1162
- finish();
1163
- return;
1164
- }
1165
- const want = Math.min(CHUNK, remaining);
1166
- fsRead(fdArg, buffer, 0, want, position, (err, bytesRead) => {
1167
- if (err) {
1168
- cleanup(err);
1169
- return;
1170
- }
1171
- if (bytesRead === 0) {
1172
- finish();
1173
- return;
1174
- }
1175
- position += bytesRead;
1176
- bytesSent += bytesRead;
1177
- remaining -= bytesRead;
1178
- // Copy the chunk so the same backing buffer can be reused on the
1179
- // next read iteration without overwriting in-flight Soup data.
1180
- const slice = Buffer.allocUnsafe(bytesRead);
1181
- buffer.copy(slice, 0, 0, bytesRead);
1182
- const ok = res.write(slice);
1183
- if (ok) {
1184
- readNext();
1185
- } else {
1186
- res.once('drain', readNext);
1187
- }
1188
- });
1189
- };
1190
-
1191
- const finish = (): void => {
1192
- res.end();
1193
- if (closeFd) {
1194
- try { closeSync(fdArg); } catch { /* ignore */ }
1195
- }
1196
- };
1197
-
1198
- const cleanup = (err: Error): void => {
1199
- if (options.onError) options.onError(err);
1200
- else res.destroy(err);
1201
- if (closeFd) {
1202
- try { closeSync(fdArg); } catch { /* ignore */ }
1203
- }
1204
- };
1205
-
1206
- // Suppress empty-body fstat path: if length===0 we just close out.
1207
- if (remaining === 0) {
1208
- finish();
1209
- return;
1210
- }
1211
-
1212
- readNext();
1213
- // Mark that we used the fd-streaming path so listeners know the body
1214
- // is being delivered out-of-band of the regular write() machinery.
1215
- void bytesSent;
1216
- }
1217
-
1218
- /**
1219
- * _fdPath — best-effort fd → path lookup via `/proc/self/fd/<fd>`.
1220
- *
1221
- * Used only for statCheck; `fs.statSync` accepts that path on Linux to
1222
- * stat the open FD. Returns null on non-Linux (caller falls back to
1223
- * `/proc/self/fd/N` regardless — `statSync` will fail cleanly).
1224
- */
1225
- function _fdPath(fd: number): string | null {
1226
- if (typeof fd !== 'number' || fd < 0) return null;
1227
- return '/proc/self/fd/' + fd;
1228
- }