@gjsify/http2 0.3.20 → 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/lib/esm/_virtual/_rolldown/runtime.js +1 -0
- package/lib/esm/client-session.js +1 -1
- package/lib/esm/index.js +1 -1
- package/lib/esm/protocol.js +1 -1
- package/lib/esm/server.js +3 -3
- package/lib/types/server.d.ts +121 -11
- package/package.json +10 -9
- package/src/http2.gjs.spec.ts +414 -72
- package/src/server.ts +507 -33
- package/tsconfig.tsbuildinfo +1 -1
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
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
313
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
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
|
-
|
|
326
|
-
|
|
432
|
+
headers: Record<string, string | string[] | number>,
|
|
433
|
+
callback: (err: Error | null, res: Http2ServerResponse) => void,
|
|
327
434
|
): void {
|
|
328
|
-
|
|
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
|
|
338
|
-
readonly pushAllowed
|
|
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
|
-
|
|
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
|
-
|
|
398
|
-
|
|
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
|
-
|
|
402
|
-
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
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
|
+
}
|