@gjsify/http 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/src/server.ts DELETED
@@ -1,503 +0,0 @@
1
- // Reference: Node.js lib/_http_server.js
2
- // Reimplemented for GJS using the @gjsify/http-soup-bridge native package.
3
- //
4
- // Why the bridge: see STATUS.md "Upstream GJS Patch Candidates" — two
5
- // distinct GJS↔libsoup binding races (Boxed-Source GC race + shared
6
- // `GMainContext` ref imbalance) make any in-JS Soup.Server wiring
7
- // SIGSEGV silently after a non-GJS HTTP client (MCP Inspector subprocess,
8
- // Node.js fetch, browser EventSource) sends a chunked SSE response. The
9
- // bridge keeps every libsoup boxed type C-side; JS only sees plain
10
- // GObject ref-counted bridge classes (`Server` / `Request` / `Response`)
11
- // whose lifetime SpiderMonkey GC cannot race against. Same pattern as
12
- // `@gjsify/webrtc-native`.
13
-
14
- import Gio from '@girs/gio-2.0';
15
- import { EventEmitter } from 'node:events';
16
- import { Writable } from 'node:stream';
17
- import { Buffer } from 'node:buffer';
18
- import { Socket as NetSocket } from '@gjsify/net/socket';
19
- import {
20
- Server as BridgeServer,
21
- type Request as BridgeRequest,
22
- type Response as BridgeResponse,
23
- } from '@gjsify/http-soup-bridge';
24
- import { ServerRequestSocket } from './server-request-socket.js';
25
- import { createNodeError, deferEmit, ensureMainLoop } from '@gjsify/utils';
26
- import { STATUS_CODES } from './constants.js';
27
- import { IncomingMessage } from './incoming-message.js';
28
-
29
- /**
30
- * OutgoingMessage — Base class for ServerResponse and ClientRequest.
31
- * Reference: Node.js lib/_http_outgoing.js
32
- */
33
- export class OutgoingMessage extends Writable {
34
- headersSent = false;
35
- sendDate = true;
36
- finished = false;
37
- socket: import('net').Socket | null = null;
38
-
39
- protected _headers: Map<string, string | string[]> = new Map();
40
-
41
- /** Set a header. */
42
- setHeader(name: string, value: string | number | string[]): this {
43
- this._headers.set(name.toLowerCase(), typeof value === 'number' ? String(value) : value);
44
- return this;
45
- }
46
-
47
- /** Get a header. */
48
- getHeader(name: string): string | string[] | undefined {
49
- return this._headers.get(name.toLowerCase());
50
- }
51
-
52
- /** Remove a header. */
53
- removeHeader(name: string): void {
54
- this._headers.delete(name.toLowerCase());
55
- }
56
-
57
- /** Check if a header has been set. */
58
- hasHeader(name: string): boolean {
59
- return this._headers.has(name.toLowerCase());
60
- }
61
-
62
- /** Get all header names. */
63
- getHeaderNames(): string[] {
64
- return Array.from(this._headers.keys());
65
- }
66
-
67
- /** Get all headers as an object. */
68
- getHeaders(): Record<string, string | string[]> {
69
- const result: Record<string, string | string[]> = {};
70
- for (const [key, value] of this._headers) {
71
- result[key] = value;
72
- }
73
- return result;
74
- }
75
-
76
- /** Append a header value instead of replacing. */
77
- appendHeader(name: string, value: string | string[]): this {
78
- const lower = name.toLowerCase();
79
- const existing = this._headers.get(lower);
80
- if (existing === undefined) {
81
- this._headers.set(lower, value);
82
- } else if (Array.isArray(existing)) {
83
- if (Array.isArray(value)) {
84
- existing.push(...value);
85
- } else {
86
- existing.push(value);
87
- }
88
- } else {
89
- if (Array.isArray(value)) {
90
- this._headers.set(lower, [existing as string, ...value]);
91
- } else {
92
- this._headers.set(lower, [existing as string, value]);
93
- }
94
- }
95
- return this;
96
- }
97
-
98
- /** Flush headers (no-op in base class). */
99
- flushHeaders(): void {
100
- this.headersSent = true;
101
- }
102
-
103
- _write(_chunk: any, _encoding: string, callback: (error?: Error | null) => void): void {
104
- callback();
105
- }
106
- }
107
-
108
- /**
109
- * ServerResponse — Writable stream representing an HTTP response.
110
- *
111
- * Holds a `BridgeResponse` from `@gjsify/http-soup-bridge`. All header /
112
- * status / body operations delegate to the bridge, which handles the
113
- * underlying Soup.ServerMessage in C-space (no JS-visible boxed types).
114
- */
115
- export class ServerResponse extends OutgoingMessage {
116
- statusCode = 200;
117
- statusMessage = '';
118
-
119
- private _streaming = false;
120
- private _bridge: BridgeResponse;
121
- private _timeoutTimer: ReturnType<typeof setTimeout> | null = null;
122
-
123
- constructor(bridge: BridgeResponse) {
124
- super();
125
- this._bridge = bridge;
126
- // Translate the bridge's `'close'` signal into a Node-style 'close'
127
- // event for consumers (Hono, MCP transport, engine.io, etc.).
128
- bridge.connect('close', () => {
129
- this.emit('close');
130
- });
131
- }
132
-
133
- /** Set a timeout for the response. Emits 'timeout' if response not sent within msecs. */
134
- setTimeout(msecs: number, callback?: () => void): this {
135
- if (this._timeoutTimer) {
136
- clearTimeout(this._timeoutTimer);
137
- this._timeoutTimer = null;
138
- }
139
- if (callback) this.once('timeout', callback);
140
- if (msecs > 0) {
141
- this._timeoutTimer = setTimeout(() => {
142
- this._timeoutTimer = null;
143
- this.emit('timeout');
144
- }, msecs);
145
- }
146
- return this;
147
- }
148
-
149
- /** Write the status line and headers. */
150
- writeHead(statusCode: number, statusMessage?: string | Record<string, string | string[]>, headers?: Record<string, string | string[]>): this {
151
- this.statusCode = statusCode;
152
-
153
- if (typeof statusMessage === 'object') {
154
- headers = statusMessage;
155
- statusMessage = undefined;
156
- }
157
-
158
- this.statusMessage = (statusMessage as string) || STATUS_CODES[statusCode] || '';
159
-
160
- if (headers) {
161
- for (const [key, value] of Object.entries(headers)) {
162
- this.setHeader(key, value);
163
- }
164
- }
165
-
166
- return this;
167
- }
168
-
169
- /** Send a 100 Continue response. */
170
- writeContinue(callback?: () => void): void {
171
- // Soup.Server handles 100-Continue automatically, but we track the call
172
- if (callback) Promise.resolve().then(callback);
173
- }
174
-
175
- /** Send a 102 Processing response (WebDAV). */
176
- writeProcessing(callback?: () => void): void {
177
- if (callback) Promise.resolve().then(callback);
178
- }
179
-
180
- /** Flush headers (send them immediately). */
181
- flushHeaders(): void {
182
- if (!this.headersSent) {
183
- this.headersSent = true;
184
- }
185
- }
186
-
187
- /** Add trailing headers for chunked transfer encoding. */
188
- addTrailers(headers: Record<string, string>): void {
189
- // Soup.Server doesn't support HTTP trailers natively. Stored for
190
- // compatibility but not transmitted.
191
- for (const [key, value] of Object.entries(headers)) {
192
- this._headers.set('trailer-' + key.toLowerCase(), value);
193
- }
194
- }
195
-
196
- /**
197
- * Push our header map to the bridge and call write_head() on first
198
- * write. Idempotent.
199
- */
200
- private _startStreaming(): void {
201
- if (this._streaming) return;
202
- this._streaming = true;
203
- this.headersSent = true;
204
-
205
- if (this._timeoutTimer) {
206
- clearTimeout(this._timeoutTimer);
207
- this._timeoutTimer = null;
208
- }
209
-
210
- for (const [key, value] of this._headers) {
211
- if (Array.isArray(value)) {
212
- for (const v of value) this._bridge.append_header(key, v);
213
- } else {
214
- this._bridge.set_header(key, value as string);
215
- }
216
- }
217
-
218
- this._bridge.write_head(this.statusCode, this.statusMessage || null);
219
- }
220
-
221
- /** Writable stream _write — sends headers on first call, then appends each chunk. */
222
- _write(chunk: any, encoding: string, callback: (error?: Error | null) => void): void {
223
- const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding as BufferEncoding);
224
- this._startStreaming();
225
- this._bridge.write_chunk(new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength));
226
- callback();
227
- }
228
-
229
- /** Called by Writable.end() — finishes the bridge response. */
230
- _final(callback: (error?: Error | null) => void): void {
231
- if (!this._streaming) {
232
- // Batch mode — push headers + status then end with no body. The
233
- // bridge's `end()` on a fresh Response sends an empty body.
234
- for (const [key, value] of this._headers) {
235
- if (Array.isArray(value)) {
236
- for (const v of value) this._bridge.append_header(key, v);
237
- } else {
238
- this._bridge.set_header(key, value as string);
239
- }
240
- }
241
- this._bridge.write_head(this.statusCode, this.statusMessage || null);
242
- }
243
- this._bridge.end();
244
- this.finished = true;
245
- callback();
246
- }
247
-
248
- /** Write status + headers + body in one call (convenience). */
249
- end(chunk?: unknown, encoding?: BufferEncoding | (() => void), callback?: () => void): this {
250
- if (typeof chunk === 'function') {
251
- callback = chunk as () => void;
252
- chunk = undefined;
253
- } else if (typeof encoding === 'function') {
254
- callback = encoding;
255
- encoding = undefined;
256
- }
257
-
258
- if (chunk != null) {
259
- this.write(chunk as string | Buffer, encoding as BufferEncoding);
260
- }
261
-
262
- super.end(callback);
263
- return this;
264
- }
265
- }
266
-
267
- // GC guard — GJS garbage-collects objects with no JS references. When
268
- // frameworks like Koa/Express create an http.Server inside .listen() and
269
- // the caller discards the return value, the Server (and its bridge
270
- // underneath) gets collected after ~10 s. This Set keeps a strong
271
- // reference to every listening server.
272
- const _activeServers = new Set<Server>();
273
-
274
- /**
275
- * HTTP Server — wraps a `@gjsify/http-soup-bridge` Server.
276
- *
277
- * Public API matches Node.js `http.Server`. Internal differences from the
278
- * pre-bridge version are invisible to consumers (Hono / Express / MCP /
279
- * engine.io / `@gjsify/ws`) — they continue to use `.on('request', …)`,
280
- * `.on('upgrade', …)`, `.listen()`, `.close()`, `.address()`, etc.
281
- */
282
- export class Server extends EventEmitter {
283
- listening = false;
284
- maxHeadersCount = 2000;
285
- timeout = 0;
286
- keepAliveTimeout = 5000;
287
- headersTimeout = 60000;
288
- requestTimeout = 300000;
289
-
290
- private _bridge: BridgeServer | null = null;
291
- private _address: { port: number; family: string; address: string } | null = null;
292
-
293
- /** Exposes the underlying Soup.Server so consumers (e.g. WebSocketServer
294
- * in `@gjsify/ws`) can register additional handlers (websocket, path-
295
- * specific) on the same server instance without port-sharing conflicts. */
296
- get soupServer(): unknown {
297
- return this._bridge?.soup_server ?? null;
298
- }
299
-
300
- constructor(requestListener?: ((req: IncomingMessage, res: ServerResponse) => void) | Record<string, unknown>);
301
- constructor(options: Record<string, unknown>, requestListener?: (req: IncomingMessage, res: ServerResponse) => void);
302
- constructor(
303
- optionsOrListener?: ((req: IncomingMessage, res: ServerResponse) => void) | Record<string, unknown>,
304
- requestListener?: (req: IncomingMessage, res: ServerResponse) => void,
305
- ) {
306
- super();
307
- const listener = typeof optionsOrListener === 'function' ? optionsOrListener : requestListener;
308
- if (listener) this.on('request', listener);
309
- }
310
-
311
- listen(port?: number, hostname?: string, backlog?: number, callback?: () => void): this;
312
- listen(port?: number, hostname?: string, callback?: () => void): this;
313
- listen(port?: number, callback?: () => void): this;
314
- listen(...args: unknown[]): this {
315
- let port = 0;
316
- let hostname = '0.0.0.0';
317
- let callback: (() => void) | undefined;
318
-
319
- for (const arg of args) {
320
- if (typeof arg === 'number') port = arg;
321
- else if (typeof arg === 'string') hostname = arg;
322
- else if (typeof arg === 'function') callback = arg as () => void;
323
- }
324
-
325
- if (callback) this.once('listening', callback);
326
-
327
- try {
328
- this._bridge = new BridgeServer();
329
-
330
- this._bridge.connect('request-received', (_self: BridgeServer, req: BridgeRequest, res: BridgeResponse) => {
331
- this._handleRequest(req, res);
332
- });
333
-
334
- this._bridge.connect('upgrade', (_self: BridgeServer, req: BridgeRequest, iostream: Gio.IOStream, _head: unknown) => {
335
- this._handleUpgrade(req, iostream);
336
- });
337
-
338
- this._bridge.connect('error-occurred', (_self: BridgeServer, msg: string) => {
339
- this.emit('error', new Error(msg));
340
- });
341
-
342
- this._bridge.listen(port, hostname);
343
-
344
- ensureMainLoop();
345
-
346
- this.listening = true;
347
- this._address = { port: this._bridge.port, family: 'IPv4', address: this._bridge.address || hostname };
348
- _activeServers.add(this);
349
- deferEmit(this, 'listening');
350
- } catch (err: unknown) {
351
- const nodeErr = createNodeError(err, 'listen', { address: hostname, port });
352
- deferEmit(this, 'error', nodeErr);
353
- }
354
-
355
- return this;
356
- }
357
-
358
- private _handleRequest(bridgeReq: BridgeRequest, bridgeRes: BridgeResponse): void {
359
- const req = new IncomingMessage();
360
- const res = new ServerResponse(bridgeRes);
361
-
362
- req.method = bridgeReq.method;
363
- req.url = bridgeReq.url;
364
- req.httpVersion = '1.1';
365
-
366
- // header_pairs is [name, value, name, value, …]
367
- const pairs = bridgeReq.header_pairs ?? [];
368
- for (let i = 0; i + 1 < pairs.length; i += 2) {
369
- const name = pairs[i];
370
- const value = pairs[i + 1];
371
- const lower = name.toLowerCase();
372
- req.rawHeaders.push(name, value);
373
- if (lower in req.headers) {
374
- const existing = req.headers[lower];
375
- if (Array.isArray(existing)) existing.push(value);
376
- else req.headers[lower] = [existing as string, value];
377
- } else {
378
- req.headers[lower] = value;
379
- }
380
- }
381
-
382
- req.socket = new ServerRequestSocket(
383
- bridgeReq.remote_address ?? '127.0.0.1',
384
- bridgeReq.remote_port ?? 0,
385
- this._address?.address ?? '127.0.0.1',
386
- this._address?.port ?? 0,
387
- bridgeRes,
388
- ) as unknown as import('net').Socket;
389
-
390
- // Push body bytes (pre-buffered by libsoup) and EOF. Body is exposed
391
- // as a method (not a property) on the bridge — GIR-marshalled
392
- // `uint8[]` properties get cleared by the time JS reads them.
393
- const body = bridgeReq.get_body();
394
- if (body.length > 0) req._pushBody(body);
395
- else req._pushBody(null);
396
-
397
- // Translate bridge 'aborted_signal' / 'close' into req events.
398
- bridgeReq.connect('aborted_signal', () => {
399
- if (!req.aborted) {
400
- req.aborted = true;
401
- req.emit('aborted');
402
- }
403
- });
404
- bridgeReq.connect('close', () => {
405
- req.emit('close');
406
- });
407
-
408
- // Emit synchronously. Async handler rejections that escape user code
409
- // are caught here so they surface on stderr instead of becoming silent
410
- // GLib-callback rejections.
411
- try {
412
- const result = this.emit('request', req, res) as unknown;
413
- if (result instanceof Promise || (result !== null && typeof result === 'object' && typeof (result as { then?: unknown }).then === 'function')) {
414
- (result as Promise<unknown>).catch((err: unknown) => {
415
- console.error('[HTTP] Unhandled error in async request handler:', err);
416
- if (!res.headersSent) {
417
- try { res.writeHead(500); res.end('Internal Server Error'); } catch { /* ignore */ }
418
- }
419
- });
420
- }
421
- } catch (err) {
422
- console.error('[HTTP] Unhandled error in request handler:', err);
423
- if (!res.headersSent) {
424
- try { res.writeHead(500); res.end('Internal Server Error'); } catch { /* ignore */ }
425
- }
426
- }
427
- }
428
-
429
- private _handleUpgrade(bridgeReq: BridgeRequest, iostream: Gio.IOStream): void {
430
- const req = new IncomingMessage();
431
- req.method = bridgeReq.method;
432
- req.url = bridgeReq.url;
433
- req.httpVersion = '1.1';
434
-
435
- const pairs = bridgeReq.header_pairs ?? [];
436
- for (let i = 0; i + 1 < pairs.length; i += 2) {
437
- const name = pairs[i];
438
- const value = pairs[i + 1];
439
- const lower = name.toLowerCase();
440
- req.rawHeaders.push(name, value);
441
- req.headers[lower] = value;
442
- }
443
-
444
- if (this.listenerCount('upgrade') > 0) {
445
- const socket = new NetSocket();
446
- socket._attachOutputOnly(iostream);
447
- this.emit('upgrade', req, socket, Buffer.alloc(0));
448
- }
449
- }
450
-
451
- address(): { port: number; family: string; address: string } | null {
452
- return this._address;
453
- }
454
-
455
- /**
456
- * Register a WebSocket handler on this server (GJS only).
457
- * Delegates to the underlying `Soup.Server.add_websocket_handler()`.
458
- * @param path URL path to handle WebSocket upgrades (e.g., '/ws')
459
- * @param callback Called for each new WebSocket connection with the Soup.WebsocketConnection
460
- */
461
- addWebSocketHandler(
462
- path: string,
463
- callback: (connection: unknown) => void,
464
- ): void {
465
- if (!this._bridge) {
466
- throw new Error('Server must be listening before adding WebSocket handlers. Call listen() first.');
467
- }
468
- const soupServer = this._bridge.soup_server as {
469
- add_websocket_handler: (
470
- p: string | null,
471
- origin: string | null,
472
- protocols: string[] | null,
473
- cb: (srv: unknown, msg: unknown, p: string, conn: unknown) => void,
474
- ) => void;
475
- };
476
- soupServer.add_websocket_handler(
477
- path, null, null,
478
- (_srv, _msg, _p, connection) => {
479
- callback(connection);
480
- },
481
- );
482
- }
483
-
484
- close(callback?: (err?: Error) => void): this {
485
- if (callback) this.once('close', callback);
486
-
487
- if (this._bridge) {
488
- this._bridge.close();
489
- this._bridge = null;
490
- }
491
-
492
- this.listening = false;
493
- _activeServers.delete(this);
494
- deferEmit(this, 'close');
495
- return this;
496
- }
497
-
498
- setTimeout(msecs: number, callback?: () => void): this {
499
- this.timeout = msecs;
500
- if (callback) this.on('timeout', callback);
501
- return this;
502
- }
503
- }