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