@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/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 +2 -2
- 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/http2.gjs.spec.ts
CHANGED
|
@@ -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:
|
|
15
|
-
): Promise<{ server:
|
|
47
|
+
handler: (req: Http2ServerRequest, res: Http2ServerResponse) => void,
|
|
48
|
+
): Promise<{ server: Http2Server; port: number }> {
|
|
16
49
|
return new Promise((resolve, reject) => {
|
|
17
|
-
const server =
|
|
50
|
+
const server = gjsHttp2.createServer(handler);
|
|
18
51
|
server.once('error', reject);
|
|
19
52
|
server.listen(0, () => {
|
|
20
|
-
const 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:
|
|
60
|
+
function collectBody(stream: ClientHttp2Stream): Promise<string> {
|
|
28
61
|
return new Promise((resolve, reject) => {
|
|
29
62
|
const chunks: Buffer[] = [];
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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('
|
|
74
|
+
await describe('gjsHttp2.createServer()', async () => {
|
|
42
75
|
await it('returns an server instance with listen/close', async () => {
|
|
43
|
-
const server =
|
|
76
|
+
const server = gjsHttp2.createServer();
|
|
44
77
|
expect(server).toBeDefined();
|
|
45
|
-
expect(typeof
|
|
46
|
-
expect(typeof
|
|
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 =
|
|
83
|
+
const server = gjsHttp2.createServer();
|
|
51
84
|
await new Promise<void>((resolve, reject) => {
|
|
52
|
-
|
|
53
|
-
|
|
85
|
+
server.once('error', reject);
|
|
86
|
+
server.listen(0, resolve);
|
|
54
87
|
});
|
|
55
|
-
expect(
|
|
56
|
-
const addr =
|
|
88
|
+
expect(server.listening).toBeTruthy();
|
|
89
|
+
const addr = server.address();
|
|
57
90
|
expect(addr).toBeDefined();
|
|
58
|
-
expect(addr
|
|
59
|
-
|
|
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 =
|
|
64
|
-
await new Promise<void>((res) =>
|
|
65
|
-
await new Promise<void>((res) =>
|
|
66
|
-
expect(
|
|
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 =
|
|
81
|
-
const stream = session.request({ ':method': 'GET', ':path': '/' }, { endStream: true }
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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 =
|
|
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 }
|
|
140
|
+
{ endStream: true },
|
|
108
141
|
);
|
|
109
142
|
|
|
110
143
|
await new Promise<void>((resolve, reject) => {
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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 =
|
|
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 }
|
|
168
|
+
{ endStream: true },
|
|
136
169
|
);
|
|
137
170
|
|
|
138
171
|
await new Promise<void>((resolve, reject) => {
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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 =
|
|
191
|
+
const session = gjsHttp2.connect(`http://localhost:${port}`);
|
|
159
192
|
const stream = session.request(
|
|
160
193
|
{ ':method': 'GET', ':path': '/' },
|
|
161
|
-
{ endStream: true }
|
|
194
|
+
{ endStream: true },
|
|
162
195
|
);
|
|
163
196
|
|
|
164
197
|
await new Promise<void>((resolve, reject) => {
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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 =
|
|
216
|
+
const session = gjsHttp2.connect(`http://localhost:${port}`);
|
|
184
217
|
const stream = session.request(
|
|
185
218
|
{ ':method': 'POST', ':path': '/items' },
|
|
186
|
-
{ endStream: true }
|
|
219
|
+
{ endStream: true },
|
|
187
220
|
);
|
|
188
221
|
|
|
189
222
|
let responseHeaders: Record<string, string | string[]> = {};
|
|
190
223
|
await new Promise<void>((resolve, reject) => {
|
|
191
|
-
|
|
224
|
+
stream.on('response', (headers: Record<string, string | string[]>) => {
|
|
192
225
|
responseHeaders = headers;
|
|
193
226
|
resolve();
|
|
194
227
|
});
|
|
195
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
229
|
-
|
|
261
|
+
stream.write('Hello');
|
|
262
|
+
stream.end(' World');
|
|
230
263
|
|
|
231
264
|
await new Promise<void>((resolve, reject) => {
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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 =
|
|
249
|
-
|
|
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) =>
|
|
259
|
-
const port =
|
|
291
|
+
await new Promise<void>((res) => server.listen(0, res));
|
|
292
|
+
const port = server.address()?.port ?? 0;
|
|
260
293
|
|
|
261
|
-
const session =
|
|
294
|
+
const session = gjsHttp2.connect(`http://localhost:${port}`);
|
|
262
295
|
const stream = session.request(
|
|
263
296
|
{ ':method': 'GET', ':path': '/' },
|
|
264
|
-
{ endStream: true }
|
|
297
|
+
{ endStream: true },
|
|
265
298
|
);
|
|
266
299
|
|
|
267
300
|
const body = await new Promise<string>((resolve, reject) => {
|
|
268
301
|
const chunks: Buffer[] = [];
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
313
|
+
server.close();
|
|
281
314
|
});
|
|
282
315
|
});
|
|
283
316
|
|
|
284
|
-
await describe('
|
|
317
|
+
await describe('gjsHttp2.connect()', async () => {
|
|
285
318
|
await it('returns a session with request() method', async () => {
|
|
286
|
-
const session =
|
|
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 =
|
|
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
|
|
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
|
|