@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.
@@ -1,35 +1,68 @@
1
1
  // GJS-only integration tests for http2 server + client (Soup.Server / Soup.Session backed)
2
2
  // These tests only run on GJS since they require Soup 3.0.
3
3
  // Wrapped in on('Gjs') — not executed on Node.js.
4
+ //
5
+ // Phase-2 tests (pushStream, respondWithFD, respondWithFile) ported from:
6
+ // refs/node-test/parallel/test-http2-server-push-stream.js (MIT, Node.js contributors)
7
+ // refs/node-test/parallel/test-http2-respond-file.js (MIT, Node.js contributors)
8
+ // refs/node-test/parallel/test-http2-respond-file-fd.js (MIT, Node.js contributors)
9
+ // Rewritten for @gjsify/unit — behavior preserved, assertion dialect adapted.
10
+ //
11
+ // Type strategy (Workstream G): runtime values come from `node:http2` so the Node
12
+ // bundle stays free of `gi://*` imports (this file is loaded by the same `test.mts`
13
+ // aggregator that also drives `test:node`, even though the suite body is no-op on
14
+ // Node via `on('Gjs', …)`). For static typing we pull the impl-private classes from
15
+ // `@gjsify/http2` via type-only imports — stripped at compile time, so the Node
16
+ // bundle is unaffected, but TypeScript sees the real shapes (`Http2Server`,
17
+ // `Http2ServerRequest/Response`, `ClientHttp2Session/Stream`) and the entire
18
+ // `as any` chain that `@types/node`'s narrower declarations would force disappears.
4
19
 
5
20
  import { describe, it, expect, on } from '@gjsify/unit';
6
21
  import http2 from 'node:http2';
22
+ import { writeFileSync, openSync, closeSync, mkdtempSync, rmSync } from 'node:fs';
23
+ import { tmpdir } from 'node:os';
24
+ import { join } from 'node:path';
25
+ import type {
26
+ Http2Server,
27
+ Http2ServerRequest,
28
+ Http2ServerResponse,
29
+ ClientHttp2Session,
30
+ ClientHttp2Stream,
31
+ ServerHttp2Stream,
32
+ } from '@gjsify/http2';
33
+
34
+ // Local view of `node:http2`'s default export retyped against our impl-private
35
+ // classes. `node:http2` is the runtime source on both Node and GJS (alias-mapped
36
+ // to `@gjsify/http2` on the GJS target by the build), but its declarations come
37
+ // from `@types/node` and don't expose the GJS-only shapes we want to assert
38
+ // against. This cast is the single boundary between the two views.
39
+ const gjsHttp2 = http2 as unknown as {
40
+ createServer(handler?: (req: Http2ServerRequest, res: Http2ServerResponse) => void): Http2Server;
41
+ connect(authority: string): ClientHttp2Session;
42
+ };
7
43
 
8
44
  // ─── Helpers ──────────────────────────────────────────────────────────────────
9
45
 
10
- type AnyServer = ReturnType<typeof http2.createServer>;
11
- type AnyStream = ReturnType<ReturnType<typeof http2.connect>['request']>;
12
-
13
46
  function withServer(
14
- handler: (req: any, res: any) => void,
15
- ): Promise<{ server: AnyServer; port: number }> {
47
+ handler: (req: Http2ServerRequest, res: Http2ServerResponse) => void,
48
+ ): Promise<{ server: Http2Server; port: number }> {
16
49
  return new Promise((resolve, reject) => {
17
- const server = http2.createServer(handler);
50
+ const server = gjsHttp2.createServer(handler);
18
51
  server.once('error', reject);
19
52
  server.listen(0, () => {
20
- const port = (server.address() as any)?.port;
53
+ const port = server.address()?.port;
21
54
  if (!port) return reject(new Error('Could not get server port'));
22
55
  resolve({ server, port });
23
56
  });
24
57
  });
25
58
  }
26
59
 
27
- function collectBody(stream: AnyStream): Promise<string> {
60
+ function collectBody(stream: ClientHttp2Stream): Promise<string> {
28
61
  return new Promise((resolve, reject) => {
29
62
  const chunks: Buffer[] = [];
30
- (stream as any).on('data', (chunk: Buffer) => chunks.push(chunk));
31
- (stream as any).on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
32
- (stream as any).on('error', reject);
63
+ stream.on('data', (chunk: Buffer) => chunks.push(chunk));
64
+ stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
65
+ stream.on('error', reject);
33
66
  });
34
67
  }
35
68
 
@@ -38,32 +71,32 @@ function collectBody(stream: AnyStream): Promise<string> {
38
71
  export default async () => {
39
72
  await on('Gjs', async () => {
40
73
 
41
- await describe('http2.createServer()', async () => {
74
+ await describe('gjsHttp2.createServer()', async () => {
42
75
  await it('returns an server instance with listen/close', async () => {
43
- const server = http2.createServer();
76
+ const server = gjsHttp2.createServer();
44
77
  expect(server).toBeDefined();
45
- expect(typeof (server as any).listen).toBe('function');
46
- expect(typeof (server as any).close).toBe('function');
78
+ expect(typeof server.listen).toBe('function');
79
+ expect(typeof server.close).toBe('function');
47
80
  });
48
81
 
49
82
  await it('listen() starts listening and emits listening', async () => {
50
- const server = http2.createServer();
83
+ const server = gjsHttp2.createServer();
51
84
  await new Promise<void>((resolve, reject) => {
52
- (server as any).once('error', reject);
53
- (server as any).listen(0, resolve);
85
+ server.once('error', reject);
86
+ server.listen(0, resolve);
54
87
  });
55
- expect((server as any).listening).toBeTruthy();
56
- const addr = (server as any).address();
88
+ expect(server.listening).toBeTruthy();
89
+ const addr = server.address();
57
90
  expect(addr).toBeDefined();
58
- expect(addr.port > 0).toBeTruthy();
59
- (server as any).close();
91
+ expect((addr?.port ?? 0) > 0).toBeTruthy();
92
+ server.close();
60
93
  });
61
94
 
62
95
  await it('close() stops listening', async () => {
63
- const server = http2.createServer();
64
- await new Promise<void>((res) => (server as any).listen(0, res));
65
- await new Promise<void>((res) => (server as any).close(res));
66
- expect((server as any).listening).toBeFalsy();
96
+ const server = gjsHttp2.createServer();
97
+ await new Promise<void>((res) => server.listen(0, res));
98
+ await new Promise<void>((res) => server.close(() => res()));
99
+ expect(server.listening).toBeFalsy();
67
100
  });
68
101
  });
69
102
 
@@ -77,17 +110,17 @@ export default async () => {
77
110
  res.end();
78
111
  });
79
112
 
80
- const session = http2.connect(`http://localhost:${port}`);
81
- const stream = session.request({ ':method': 'GET', ':path': '/' }, { endStream: true } as any);
113
+ const session = gjsHttp2.connect(`http://localhost:${port}`);
114
+ const stream = session.request({ ':method': 'GET', ':path': '/' }, { endStream: true });
82
115
 
83
116
  await new Promise<void>((resolve, reject) => {
84
- (stream as any).on('response', () => resolve());
85
- (stream as any).on('error', reject);
117
+ stream.on('response', () => resolve());
118
+ stream.on('error', reject);
86
119
  setTimeout(() => reject(new Error('timeout')), 5000);
87
120
  });
88
121
 
89
122
  session.close();
90
- (server as any).close();
123
+ server.close();
91
124
  });
92
125
 
93
126
  await it('req.method and req.url are populated', async () => {
@@ -101,15 +134,15 @@ export default async () => {
101
134
  res.end();
102
135
  });
103
136
 
104
- const session = http2.connect(`http://localhost:${port}`);
137
+ const session = gjsHttp2.connect(`http://localhost:${port}`);
105
138
  const stream = session.request(
106
139
  { ':method': 'GET', ':path': '/hello?foo=bar' },
107
- { endStream: true } as any,
140
+ { endStream: true },
108
141
  );
109
142
 
110
143
  await new Promise<void>((resolve, reject) => {
111
- (stream as any).on('response', () => resolve());
112
- (stream as any).on('error', reject);
144
+ stream.on('response', () => resolve());
145
+ stream.on('error', reject);
113
146
  setTimeout(() => reject(new Error('timeout')), 5000);
114
147
  });
115
148
 
@@ -117,7 +150,7 @@ export default async () => {
117
150
  expect(capturedUrl).toBe('/hello?foo=bar');
118
151
 
119
152
  session.close();
120
- (server as any).close();
153
+ server.close();
121
154
  });
122
155
 
123
156
  await it('req.headers contains custom request headers', async () => {
@@ -129,22 +162,22 @@ export default async () => {
129
162
  res.end();
130
163
  });
131
164
 
132
- const session = http2.connect(`http://localhost:${port}`);
165
+ const session = gjsHttp2.connect(`http://localhost:${port}`);
133
166
  const stream = session.request(
134
167
  { ':method': 'GET', ':path': '/', 'x-custom': 'test-value' },
135
- { endStream: true } as any,
168
+ { endStream: true },
136
169
  );
137
170
 
138
171
  await new Promise<void>((resolve, reject) => {
139
- (stream as any).on('response', () => resolve());
140
- (stream as any).on('error', reject);
172
+ stream.on('response', () => resolve());
173
+ stream.on('error', reject);
141
174
  setTimeout(() => reject(new Error('timeout')), 5000);
142
175
  });
143
176
 
144
177
  expect(capturedHeaders['x-custom']).toBe('test-value');
145
178
 
146
179
  session.close();
147
- (server as any).close();
180
+ server.close();
148
181
  });
149
182
  });
150
183
 
@@ -155,15 +188,15 @@ export default async () => {
155
188
  res.end('Hello HTTP/2');
156
189
  });
157
190
 
158
- const session = http2.connect(`http://localhost:${port}`);
191
+ const session = gjsHttp2.connect(`http://localhost:${port}`);
159
192
  const stream = session.request(
160
193
  { ':method': 'GET', ':path': '/' },
161
- { endStream: true } as any,
194
+ { endStream: true },
162
195
  );
163
196
 
164
197
  await new Promise<void>((resolve, reject) => {
165
- (stream as any).on('response', () => resolve());
166
- (stream as any).on('error', reject);
198
+ stream.on('response', () => resolve());
199
+ stream.on('error', reject);
167
200
  setTimeout(() => reject(new Error('timeout')), 5000);
168
201
  });
169
202
  const body = await collectBody(stream);
@@ -171,7 +204,7 @@ export default async () => {
171
204
  expect(body).toBe('Hello HTTP/2');
172
205
 
173
206
  session.close();
174
- (server as any).close();
207
+ server.close();
175
208
  });
176
209
 
177
210
  await it(':status is included in response headers', async () => {
@@ -180,19 +213,19 @@ export default async () => {
180
213
  res.end('created');
181
214
  });
182
215
 
183
- const session = http2.connect(`http://localhost:${port}`);
216
+ const session = gjsHttp2.connect(`http://localhost:${port}`);
184
217
  const stream = session.request(
185
218
  { ':method': 'POST', ':path': '/items' },
186
- { endStream: true } as any,
219
+ { endStream: true },
187
220
  );
188
221
 
189
222
  let responseHeaders: Record<string, string | string[]> = {};
190
223
  await new Promise<void>((resolve, reject) => {
191
- (stream as any).on('response', (headers: any) => {
224
+ stream.on('response', (headers: Record<string, string | string[]>) => {
192
225
  responseHeaders = headers;
193
226
  resolve();
194
227
  });
195
- (stream as any).on('error', reject);
228
+ stream.on('error', reject);
196
229
  setTimeout(() => reject(new Error('timeout')), 5000);
197
230
  });
198
231
  await collectBody(stream);
@@ -200,7 +233,7 @@ export default async () => {
200
233
  expect(responseHeaders[':status']).toBe('201');
201
234
 
202
235
  session.close();
203
- (server as any).close();
236
+ server.close();
204
237
  });
205
238
  });
206
239
 
@@ -218,26 +251,26 @@ export default async () => {
218
251
  res.end('ok');
219
252
  });
220
253
 
221
- const session = http2.connect(`http://localhost:${port}`);
254
+ const session = gjsHttp2.connect(`http://localhost:${port}`);
222
255
  const stream = session.request({
223
256
  ':method': 'POST',
224
257
  ':path': '/upload',
225
258
  'content-type': 'text/plain',
226
259
  });
227
260
 
228
- (stream as any).write('Hello');
229
- (stream as any).end(' World');
261
+ stream.write('Hello');
262
+ stream.end(' World');
230
263
 
231
264
  await new Promise<void>((resolve, reject) => {
232
- (stream as any).on('response', () => resolve());
233
- (stream as any).on('error', reject);
265
+ stream.on('response', () => resolve());
266
+ stream.on('error', reject);
234
267
  setTimeout(() => reject(new Error('timeout')), 5000);
235
268
  });
236
269
 
237
270
  expect(capturedBody).toBe('Hello World');
238
271
 
239
272
  session.close();
240
- (server as any).close();
273
+ server.close();
241
274
  });
242
275
  });
243
276
 
@@ -245,8 +278,8 @@ export default async () => {
245
278
  await it('server emits stream event with headers', async () => {
246
279
  let streamEventFired = false;
247
280
 
248
- const server = http2.createServer();
249
- (server as any).on('stream', (stream: any, headers: any) => {
281
+ const server = gjsHttp2.createServer();
282
+ server.on('stream', (stream: ServerHttp2Stream, headers: Record<string, string | string[]>) => {
250
283
  streamEventFired = true;
251
284
  expect(stream).toBeDefined();
252
285
  expect(typeof stream.respond).toBe('function');
@@ -255,21 +288,21 @@ export default async () => {
255
288
  stream.end('stream API response');
256
289
  });
257
290
 
258
- await new Promise<void>((res) => (server as any).listen(0, res));
259
- const port = (server as any).address()?.port ?? 0;
291
+ await new Promise<void>((res) => server.listen(0, res));
292
+ const port = server.address()?.port ?? 0;
260
293
 
261
- const session = http2.connect(`http://localhost:${port}`);
294
+ const session = gjsHttp2.connect(`http://localhost:${port}`);
262
295
  const stream = session.request(
263
296
  { ':method': 'GET', ':path': '/' },
264
- { endStream: true } as any,
297
+ { endStream: true },
265
298
  );
266
299
 
267
300
  const body = await new Promise<string>((resolve, reject) => {
268
301
  const chunks: Buffer[] = [];
269
- (stream as any).on('response', () => {});
270
- (stream as any).on('data', (chunk: Buffer) => chunks.push(chunk));
271
- (stream as any).on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
272
- (stream as any).on('error', reject);
302
+ stream.on('response', () => {});
303
+ stream.on('data', (chunk: Buffer) => chunks.push(chunk));
304
+ stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
305
+ stream.on('error', reject);
273
306
  setTimeout(() => reject(new Error('timeout')), 5000);
274
307
  });
275
308
 
@@ -277,13 +310,13 @@ export default async () => {
277
310
  expect(streamEventFired).toBeTruthy();
278
311
 
279
312
  session.close();
280
- (server as any).close();
313
+ server.close();
281
314
  });
282
315
  });
283
316
 
284
- await describe('http2.connect()', async () => {
317
+ await describe('gjsHttp2.connect()', async () => {
285
318
  await it('returns a session with request() method', async () => {
286
- const session = http2.connect('http://localhost:19999');
319
+ const session: ClientHttp2Session = gjsHttp2.connect('http://localhost:19999');
287
320
  expect(session).toBeDefined();
288
321
  expect(typeof session.request).toBe('function');
289
322
  expect(typeof session.close).toBe('function');
@@ -291,11 +324,320 @@ export default async () => {
291
324
  });
292
325
 
293
326
  await it('session.request() returns a stream with on()', async () => {
294
- const session = http2.connect('http://localhost:19999');
327
+ const session = gjsHttp2.connect('http://localhost:19999');
295
328
  const stream = session.request({ ':method': 'GET', ':path': '/' });
296
329
  expect(stream).toBeDefined();
297
- expect(typeof (stream as any).on).toBe('function');
330
+ expect(typeof stream.on).toBe('function');
331
+ session.close();
332
+ });
333
+ });
334
+
335
+ // ─── Phase 2: respondWithFile / respondWithFD ────────────────────────────
336
+ // Ported from refs/node-test/parallel/test-http2-respond-file.js
337
+ // refs/node-test/parallel/test-http2-respond-file-fd.js
338
+ // Original: Copyright (c) Node.js contributors. MIT.
339
+
340
+ await describe('http2 respondWithFile()', async () => {
341
+ let tmpDir = '';
342
+ let fname = '';
343
+ const data = 'Hello from a file served by http2.respondWithFile!\n';
344
+
345
+ tmpDir = mkdtempSync(join(tmpdir(), 'gjsify-http2-'));
346
+ fname = join(tmpDir, 'sample.txt');
347
+ writeFileSync(fname, data);
348
+
349
+ await it('streams a file by path through the response body', async () => {
350
+ const { server, port } = await withServer((_req, res) => {
351
+ (res as any).respondWithFile(fname, { 'content-type': 'text/plain' });
352
+ });
353
+
354
+ const session = gjsHttp2.connect(`http://localhost:${port}`);
355
+ const stream = session.request(
356
+ { ':method': 'GET', ':path': '/' },
357
+ { endStream: true } as any,
358
+ );
359
+
360
+ let captured: Record<string, string | string[]> = {};
361
+ await new Promise<void>((resolve, reject) => {
362
+ (stream as any).on('response', (h: any) => { captured = h; resolve(); });
363
+ (stream as any).on('error', reject);
364
+ setTimeout(() => reject(new Error('timeout')), 5000);
365
+ });
366
+ const body = await collectBody(stream);
367
+
368
+ expect(body).toBe(data);
369
+ expect(captured['content-type']).toBe('text/plain');
370
+
371
+ session.close();
372
+ (server as any).close();
373
+ });
374
+
375
+ await it('statCheck() can mutate response headers based on file stat', async () => {
376
+ let statSeen: any = null;
377
+ const { server, port } = await withServer((_req, res) => {
378
+ (res as any).respondWithFile(
379
+ fname,
380
+ { 'content-type': 'text/plain' },
381
+ {
382
+ statCheck: (stat: any, headers: any) => {
383
+ statSeen = stat;
384
+ headers['content-length'] = String(stat.size);
385
+ headers['x-stat-mtime'] = String(stat.mtimeMs ?? stat.mtime?.getTime?.() ?? 0);
386
+ },
387
+ },
388
+ );
389
+ });
390
+
391
+ const session = gjsHttp2.connect(`http://localhost:${port}`);
392
+ const stream = session.request(
393
+ { ':method': 'GET', ':path': '/' },
394
+ { endStream: true } as any,
395
+ );
396
+
397
+ let captured: Record<string, string | string[]> = {};
398
+ await new Promise<void>((resolve, reject) => {
399
+ (stream as any).on('response', (h: any) => { captured = h; resolve(); });
400
+ (stream as any).on('error', reject);
401
+ setTimeout(() => reject(new Error('timeout')), 5000);
402
+ });
403
+ await collectBody(stream);
404
+
405
+ expect(statSeen).toBeTruthy();
406
+ expect(Number(captured['content-length'])).toBe(data.length);
407
+ expect(typeof captured['x-stat-mtime']).toBe('string');
408
+
409
+ session.close();
410
+ (server as any).close();
411
+ });
412
+
413
+ // Cleanup. Best-effort — tmpdir lives under /tmp so test failures don't pollute.
414
+ await it('cleanup', async () => {
415
+ try { rmSync(tmpDir, { recursive: true, force: true }); } catch {}
416
+ });
417
+ });
418
+
419
+ await describe('http2 respondWithFD()', async () => {
420
+ let tmpDir = '';
421
+ let fname = '';
422
+ const data = 'FD-streamed body\n';
423
+
424
+ tmpDir = mkdtempSync(join(tmpdir(), 'gjsify-http2-fd-'));
425
+ fname = join(tmpDir, 'fd.txt');
426
+ writeFileSync(fname, data);
427
+
428
+ await it('streams an open file descriptor through the response body', async () => {
429
+ const fd = openSync(fname, 'r');
430
+ const { server, port } = await withServer((_req, res) => {
431
+ (res as any).respondWithFD(fd, {
432
+ 'content-type': 'text/plain',
433
+ 'content-length': String(data.length),
434
+ });
435
+ });
436
+
437
+ const session = gjsHttp2.connect(`http://localhost:${port}`);
438
+ const stream = session.request(
439
+ { ':method': 'GET', ':path': '/' },
440
+ { endStream: true } as any,
441
+ );
442
+
443
+ let captured: Record<string, string | string[]> = {};
444
+ await new Promise<void>((resolve, reject) => {
445
+ (stream as any).on('response', (h: any) => { captured = h; resolve(); });
446
+ (stream as any).on('error', reject);
447
+ setTimeout(() => reject(new Error('timeout')), 5000);
448
+ });
449
+ const body = await collectBody(stream);
450
+
451
+ expect(body).toBe(data);
452
+ expect(Number(captured['content-length'])).toBe(data.length);
453
+
454
+ session.close();
455
+ (server as any).close();
456
+ try { closeSync(fd); } catch {}
457
+ });
458
+
459
+ await it('cleanup', async () => {
460
+ try { rmSync(tmpDir, { recursive: true, force: true }); } catch {}
461
+ });
462
+ });
463
+
464
+ // ─── Phase 2: pushStream ─────────────────────────────────────────────────
465
+ // Ported from refs/node-test/parallel/test-http2-server-push-stream.js
466
+ // Original: Copyright (c) Node.js contributors. MIT.
467
+ //
468
+ // Soup does not expose the underlying nghttp2 session, so the
469
+ // PUSH_PROMISE frame the bridge constructs cannot be delivered to the
470
+ // client over the active Soup connection. These tests verify the
471
+ // server-side API contract instead: callback fired, ServerHttp2Stream
472
+ // synthesised, even stream-id allocated, headers + frame bytes
473
+ // observable. See STATUS.md "Open TODOs" → "http2 PUSH_PROMISE wire
474
+ // delivery" for the wire-level follow-up.
475
+
476
+ await describe('http2 pushStream() — API contract', async () => {
477
+ await it('callback fires with a ServerHttp2Stream + push headers', async () => {
478
+ let pushedStream: any = null;
479
+ let pushedHeaders: any = null;
480
+
481
+ const { server, port } = await withServer((_req, res) => {
482
+ (res as any).pushStream(
483
+ {
484
+ ':path': '/foobar',
485
+ ':authority': `localhost:${port}`,
486
+ ':scheme': 'http',
487
+ },
488
+ (err: Error | null, stream: any, hdrs: any) => {
489
+ if (err) { res.writeHead(500); res.end(); return; }
490
+ pushedStream = stream;
491
+ pushedHeaders = hdrs;
492
+ stream.respond({ ':status': 200, 'content-type': 'text/plain' });
493
+ stream.end('pushed body');
494
+ res.writeHead(200);
495
+ res.end('main body');
496
+ },
497
+ );
498
+ });
499
+
500
+ const session = gjsHttp2.connect(`http://localhost:${port}`);
501
+ const stream = session.request({ ':method': 'GET', ':path': '/' }, { endStream: true } as any);
502
+
503
+ await new Promise<void>((resolve, reject) => {
504
+ (stream as any).on('response', () => resolve());
505
+ (stream as any).on('error', reject);
506
+ setTimeout(() => reject(new Error('timeout')), 5000);
507
+ });
508
+ await collectBody(stream);
509
+
510
+ expect(pushedStream).toBeTruthy();
511
+ expect(typeof pushedStream.respond).toBe('function');
512
+ expect(pushedHeaders).toBeTruthy();
513
+ expect(pushedHeaders[':path']).toBe('/foobar');
514
+ // Even server-allocated stream id (RFC 7540 §5.1.1).
515
+ expect(pushedStream.id % 2 === 0).toBeTruthy();
516
+ expect(pushedStream.id >= 2).toBeTruthy();
517
+
518
+ session.close();
519
+ (server as any).close();
520
+ });
521
+
522
+ await it('pushStream on a pushed stream rejects with ERR_HTTP2_NESTED_PUSH', async () => {
523
+ let nestedError: any = null;
524
+
525
+ const { server, port } = await withServer((_req, res) => {
526
+ (res as any).pushStream(
527
+ { ':path': '/lvl1', ':authority': `localhost:${port}`, ':scheme': 'http' },
528
+ (err: Error | null, stream: any) => {
529
+ if (err) { res.writeHead(500); res.end(); return; }
530
+ // Nested push must fail per RFC 7540 §8.2.
531
+ stream.pushStream(
532
+ { ':path': '/lvl2', ':authority': `localhost:${port}`, ':scheme': 'http' },
533
+ (innerErr: any) => { nestedError = innerErr; },
534
+ );
535
+ stream.respond({ ':status': 200 });
536
+ stream.end('lvl1 body');
537
+ res.writeHead(200);
538
+ res.end('main');
539
+ },
540
+ );
541
+ });
542
+
543
+ const session = gjsHttp2.connect(`http://localhost:${port}`);
544
+ const stream = session.request({ ':method': 'GET', ':path': '/' }, { endStream: true } as any);
545
+ await new Promise<void>((resolve, reject) => {
546
+ (stream as any).on('response', () => resolve());
547
+ (stream as any).on('error', reject);
548
+ setTimeout(() => reject(new Error('timeout')), 5000);
549
+ });
550
+ await collectBody(stream);
551
+
552
+ // Allow the async push callback chain to settle.
553
+ await new Promise((r) => setTimeout(r, 20));
554
+
555
+ expect(nestedError).toBeTruthy();
556
+ expect((nestedError as any).code).toBe('ERR_HTTP2_NESTED_PUSH');
557
+
558
+ session.close();
559
+ (server as any).close();
560
+ });
561
+
562
+ await it('allocates monotonically-increasing even ids across multiple pushes', async () => {
563
+ const ids: number[] = [];
564
+ const { server, port } = await withServer(async (_req, res) => {
565
+ await new Promise<void>((res2) => {
566
+ (res as any).pushStream(
567
+ { ':path': '/a', ':authority': `localhost:${port}`, ':scheme': 'http' },
568
+ (err: any, s1: any) => {
569
+ if (s1) ids.push(s1.id);
570
+ if (s1) { s1.respond({ ':status': 200 }); s1.end('a'); }
571
+ (res as any).pushStream(
572
+ { ':path': '/b', ':authority': `localhost:${port}`, ':scheme': 'http' },
573
+ (err2: any, s2: any) => {
574
+ if (s2) ids.push(s2.id);
575
+ if (s2) { s2.respond({ ':status': 200 }); s2.end('b'); }
576
+ res2();
577
+ },
578
+ );
579
+ },
580
+ );
581
+ });
582
+ res.writeHead(200);
583
+ res.end('main');
584
+ });
585
+
586
+ const session = gjsHttp2.connect(`http://localhost:${port}`);
587
+ const stream = session.request({ ':method': 'GET', ':path': '/' }, { endStream: true } as any);
588
+ await new Promise<void>((resolve, reject) => {
589
+ (stream as any).on('response', () => resolve());
590
+ (stream as any).on('error', reject);
591
+ setTimeout(() => reject(new Error('timeout')), 5000);
592
+ });
593
+ await collectBody(stream);
594
+
595
+ expect(ids.length).toBe(2);
596
+ expect(ids[0] % 2 === 0).toBeTruthy();
597
+ expect(ids[1] % 2 === 0).toBeTruthy();
598
+ expect(ids[1] > ids[0]).toBeTruthy();
599
+
298
600
  session.close();
601
+ (server as any).close();
602
+ });
603
+
604
+ await it('createPushResponse() yields a usable Http2ServerResponse', async () => {
605
+ let pushRes: any = null;
606
+ const { server, port } = await withServer((_req, res) => {
607
+ (res as any).createPushResponse(
608
+ { ':path': '/aux', ':authority': `localhost:${port}`, ':scheme': 'http' },
609
+ (err: any, child: any) => {
610
+ if (child) {
611
+ pushRes = child;
612
+ child.writeHead(200, { 'content-type': 'text/plain' });
613
+ child.end('pushed');
614
+ }
615
+ res.writeHead(200);
616
+ res.end('parent');
617
+ },
618
+ );
619
+ });
620
+
621
+ const session = gjsHttp2.connect(`http://localhost:${port}`);
622
+ const stream = session.request({ ':method': 'GET', ':path': '/' }, { endStream: true } as any);
623
+ await new Promise<void>((resolve, reject) => {
624
+ (stream as any).on('response', () => resolve());
625
+ (stream as any).on('error', reject);
626
+ setTimeout(() => reject(new Error('timeout')), 5000);
627
+ });
628
+ await collectBody(stream);
629
+
630
+ await new Promise((r) => setTimeout(r, 20));
631
+
632
+ expect(pushRes).toBeTruthy();
633
+ expect(pushRes.statusCode).toBe(200);
634
+ // The detached body is observable on push responses.
635
+ const buf = pushRes.detachedBody;
636
+ expect(buf).toBeTruthy();
637
+ if (buf) expect(buf.toString('utf8')).toBe('pushed');
638
+
639
+ session.close();
640
+ (server as any).close();
299
641
  });
300
642
  });
301
643