@gjsify/http2 0.3.21 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/server.ts CHANGED
@@ -1,11 +1,26 @@
1
1
  // Reference: Node.js lib/internal/http2/compat.js, lib/_http_server.js
2
2
  // Reimplemented for GJS using Soup.Server (HTTP/2 transparently via ALPN when TLS is used)
3
3
  //
4
- // Phase 1 limitations:
4
+ // Phase 1 limitations (resolved in Phase 2):
5
5
  // - createServer() serves HTTP/1.1 only (Soup does not support h2c/cleartext HTTP/2)
6
6
  // - createSecureServer() negotiates h2 via ALPN automatically when TLS cert is set
7
- // - pushStream(), respondWithFD(), respondWithFile() are stubs (Phase 2)
7
+ // - pushStream(), respondWithFD(), respondWithFile() are stubs
8
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)
9
24
 
10
25
  import Soup from '@girs/soup-3.0';
11
26
  import Gio from '@girs/gio-2.0';
@@ -13,7 +28,9 @@ import GLib from '@girs/glib-2.0';
13
28
  import { EventEmitter } from 'node:events';
14
29
  import { Readable, Writable } from 'node:stream';
15
30
  import { Buffer } from 'node:buffer';
31
+ import { read as fsRead, createReadStream, statSync, openSync, closeSync } from 'node:fs';
16
32
  import { deferEmit, ensureMainLoop } from '@gjsify/utils';
33
+ import { hasNativeHttp2, loadNativeHttp2 } from '@gjsify/http2-native';
17
34
  import { constants, getDefaultSettings, type Http2Settings } from './protocol.js';
18
35
 
19
36
  export type { Http2Settings };
@@ -99,23 +116,32 @@ export class Http2ServerResponse extends Writable {
99
116
  finished = false;
100
117
  sendDate = true;
101
118
 
102
- private _soupMsg: Soup.ServerMessage;
119
+ private _soupMsg: Soup.ServerMessage | null;
103
120
  private _headers: Map<string, string | string[]> = new Map();
104
121
  private _streaming = false;
105
122
  private _timeoutTimer: ReturnType<typeof setTimeout> | null = null;
106
123
  private _stream: ServerHttp2Stream | null = null;
124
+ /** Detached responses (PUSH_PROMISE children) buffer their output. */
125
+ private _detachedBody: Buffer[] | null = null;
107
126
 
108
127
  get stream(): ServerHttp2Stream | null { return this._stream; }
109
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
+ }
110
135
 
111
136
  // Called by Http2Server after stream is created
112
137
  _setStream(stream: ServerHttp2Stream): void {
113
138
  this._stream = stream;
114
139
  }
115
140
 
116
- constructor(soupMsg: Soup.ServerMessage) {
141
+ constructor(soupMsg: Soup.ServerMessage | null) {
117
142
  super();
118
143
  this._soupMsg = soupMsg;
144
+ if (soupMsg === null) this._detachedBody = [];
119
145
  }
120
146
 
121
147
  setHeader(name: string, value: string | number | string[]): this {
@@ -226,6 +252,8 @@ export class Http2ServerResponse extends Writable {
226
252
  this._timeoutTimer = null;
227
253
  }
228
254
 
255
+ if (!this._soupMsg) return; // detached push response — no Soup wire
256
+
229
257
  this._soupMsg.set_status(this.statusCode, this.statusMessage || null);
230
258
  const responseHeaders = this._soupMsg.get_response_headers();
231
259
 
@@ -247,17 +275,23 @@ export class Http2ServerResponse extends Writable {
247
275
  _write(chunk: any, encoding: string, callback: (error?: Error | null) => void): void {
248
276
  const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding as BufferEncoding);
249
277
  this._startStreaming();
250
- const responseBody = this._soupMsg.get_response_body();
251
- responseBody.append(new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength));
252
- this._soupMsg.unpause();
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
+ }
253
285
  callback();
254
286
  }
255
287
 
256
288
  _final(callback: (error?: Error | null) => void): void {
257
289
  if (this._streaming) {
258
- const responseBody = this._soupMsg.get_response_body();
259
- responseBody.complete();
260
- this._soupMsg.unpause();
290
+ if (this._soupMsg) {
291
+ const responseBody = this._soupMsg.get_response_body();
292
+ responseBody.complete();
293
+ this._soupMsg.unpause();
294
+ }
261
295
  } else {
262
296
  this._sendBatchResponse();
263
297
  }
@@ -274,6 +308,8 @@ export class Http2ServerResponse extends Writable {
274
308
  this._timeoutTimer = null;
275
309
  }
276
310
 
311
+ if (!this._soupMsg) return;
312
+
277
313
  this._soupMsg.set_status(this.statusCode, this.statusMessage || null);
278
314
  const responseHeaders = this._soupMsg.get_response_headers();
279
315
 
@@ -304,28 +340,111 @@ export class Http2ServerResponse extends Writable {
304
340
  return this;
305
341
  }
306
342
 
307
- // respondWithFD and respondWithFile stubs (Phase 2)
308
- respondWithFD(_fd: any, _headers?: any, _options?: any): void {
309
- throw new Error('http2 respondWithFD is not yet implemented in GJS (Phase 2)');
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);
310
357
  }
311
358
 
312
- respondWithFile(_path: string, _headers?: any, _options?: any): void {
313
- throw new Error('http2 respondWithFile is not yet implemented in GJS (Phase 2)');
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);
314
388
  }
315
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
+ */
316
402
  pushStream(
317
- _headers: Record<string, string | string[]>,
318
- _options: any,
319
- _callback: (err: Error | null, pushStream: any, headers: Record<string, string | string[]>) => void,
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,
320
408
  ): void {
321
- throw new Error('http2 server push is not yet implemented in GJS (Phase 2)');
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);
322
422
  }
323
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
+ */
324
431
  createPushResponse(
325
- _headers: Record<string, string | string[]>,
326
- _callback: (err: Error | null, res: Http2ServerResponse) => void,
432
+ headers: Record<string, string | string[] | number>,
433
+ callback: (err: Error | null, res: Http2ServerResponse) => void,
327
434
  ): void {
328
- throw new Error('http2 server push is not yet implemented in GJS (Phase 2)');
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
+ });
329
448
  }
330
449
  }
331
450
 
@@ -334,12 +453,19 @@ export class Http2ServerResponse extends Writable {
334
453
  // Delegates all writes to the underlying response object.
335
454
 
336
455
  export class ServerHttp2Stream extends EventEmitter {
337
- readonly id = 1;
338
- readonly pushAllowed = false;
456
+ readonly id: number;
457
+ readonly pushAllowed: boolean;
339
458
  readonly sentHeaders: Record<string, string | string[]> = {};
340
459
 
341
460
  private _res: Http2ServerResponse;
342
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;
343
469
 
344
470
  get session(): ServerHttp2Session | null { return this._session; }
345
471
  get headersSent(): boolean { return this._res.headersSent; }
@@ -350,10 +476,30 @@ export class ServerHttp2Stream extends EventEmitter {
350
476
  return this.closed ? constants.NGHTTP2_STREAM_STATE_CLOSED : constants.NGHTTP2_STREAM_STATE_OPEN;
351
477
  }
352
478
 
353
- constructor(res: Http2ServerResponse, session: ServerHttp2Session | null = null) {
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
+ ) {
354
491
  super();
355
492
  this._res = res;
356
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;
357
503
 
358
504
  res.on('finish', () => this.emit('close'));
359
505
  res.on('error', (err: Error) => this.emit('error', err));
@@ -394,20 +540,123 @@ export class ServerHttp2Stream extends EventEmitter {
394
540
  sendTrailers(_headers: Record<string, string | string[]>): void {}
395
541
  additionalHeaders(_headers: Record<string, string | string[]>): void {}
396
542
 
397
- respondWithFD(_fd: any, _headers?: any, _options?: any): void {
398
- throw new Error('http2 respondWithFD is not yet implemented in GJS (Phase 2)');
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);
399
550
  }
400
551
 
401
- respondWithFile(_path: string, _headers?: any, _options?: any): void {
402
- throw new Error('http2 respondWithFile is not yet implemented in GJS (Phase 2)');
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);
403
564
  }
404
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
+ */
405
574
  pushStream(
406
- _headers: Record<string, string | string[]>,
407
- _options: any,
408
- _callback: (err: Error | null, pushStream: ServerHttp2Stream, headers: Record<string, string | string[]>) => void,
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,
409
580
  ): void {
410
- throw new Error('http2 server push is not yet implemented in GJS (Phase 2)');
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
+ });
411
660
  }
412
661
  }
413
662
 
@@ -421,12 +670,79 @@ export class ServerHttp2Session extends EventEmitter {
421
670
  private _closed = false;
422
671
  private _destroyed = false;
423
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;
424
679
 
425
680
  constructor() {
426
681
  super();
427
682
  this._settings = getDefaultSettings();
428
683
  }
429
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
+
430
746
  get closed(): boolean { return this._closed; }
431
747
  get destroyed(): boolean { return this._destroyed; }
432
748
  get pendingSettingsAck(): boolean { return false; }
@@ -752,3 +1068,161 @@ function _createTlsCertificate(certPem: string, keyPem: string): Gio.TlsCertific
752
1068
  }
753
1069
  }
754
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
+ }