@gjsify/http 0.1.15 → 0.2.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.
@@ -0,0 +1,73 @@
1
+ // Ported from refs/node-test/parallel/test-net-server-listen-handle.js
2
+ // Original: MIT license, Node.js contributors
3
+ // Tests that http.Server emits 'error' with code EADDRINUSE when port is busy.
4
+
5
+ import { describe, it, expect } from '@gjsify/unit';
6
+ import * as http from 'node:http';
7
+
8
+ export default async () => {
9
+
10
+ await describe('http.Server listen error', async () => {
11
+
12
+ await it('should emit error with EADDRINUSE when port is already in use', async () => {
13
+ // First server binds a random port
14
+ const server1 = http.createServer();
15
+ const port = await new Promise<number>((resolve, reject) => {
16
+ server1.on('error', reject);
17
+ server1.listen(0, () => {
18
+ const addr = server1.address() as { port: number };
19
+ resolve(addr.port);
20
+ });
21
+ });
22
+
23
+ try {
24
+ // Second server tries to bind the same port — must emit 'error'
25
+ const error = await new Promise<any>((resolve, reject) => {
26
+ const server2 = http.createServer();
27
+ server2.on('error', resolve);
28
+ server2.on('listening', () => {
29
+ server2.close();
30
+ reject(new Error('Expected EADDRINUSE but server started successfully'));
31
+ });
32
+ server2.listen(port);
33
+ });
34
+
35
+ expect(error.code).toBe('EADDRINUSE');
36
+ expect(error.syscall).toBe('listen');
37
+ expect(typeof error.port).toBe('number');
38
+ expect(error.port).toBe(port);
39
+ } finally {
40
+ server1.close();
41
+ }
42
+ });
43
+
44
+ await it('should include address and port in error message', async () => {
45
+ const server1 = http.createServer();
46
+ const port = await new Promise<number>((resolve, reject) => {
47
+ server1.on('error', reject);
48
+ server1.listen(0, () => {
49
+ const addr = server1.address() as { port: number };
50
+ resolve(addr.port);
51
+ });
52
+ });
53
+
54
+ try {
55
+ const error = await new Promise<any>((resolve, reject) => {
56
+ const server2 = http.createServer();
57
+ server2.on('error', resolve);
58
+ server2.on('listening', () => {
59
+ server2.close();
60
+ reject(new Error('Expected EADDRINUSE'));
61
+ });
62
+ server2.listen(port);
63
+ });
64
+
65
+ expect(error.message).toContain('EADDRINUSE');
66
+ expect(error.message).toContain('listen');
67
+ } finally {
68
+ server1.close();
69
+ }
70
+ });
71
+
72
+ });
73
+ };
@@ -0,0 +1,88 @@
1
+ // `req.socket` for our HTTP server.
2
+ //
3
+ // Reference: Node.js lib/net.js Socket interface
4
+ // Reimplemented for GJS — `@gjsify/http-soup-bridge` owns the underlying
5
+ // TCP connection, so a real `Gio.Socket` is not directly accessible from
6
+ // JS. This class satisfies the net.Socket duck-type expected by HTTP
7
+ // consumers (Hono, MCP SDK, engine.io, …) using values copied off the
8
+ // bridge `Request` instance at construction time.
9
+ //
10
+ // Extends Duplex (not EventEmitter) so that `instanceof stream.Duplex`
11
+ // checks and stream API calls (`pipe`, `pause`, `resume`) work. `_read`
12
+ // and `_write` are no-ops because the bridge owns the actual bytes.
13
+
14
+ import { Duplex } from 'node:stream';
15
+ import type { Response as BridgeResponse } from '@gjsify/http-soup-bridge';
16
+
17
+ export class ServerRequestSocket extends Duplex {
18
+ readonly remoteAddress: string;
19
+ readonly remotePort: number;
20
+ readonly localAddress: string;
21
+ readonly localPort: number;
22
+ readonly remoteFamily = 'IPv4';
23
+ readonly encrypted: boolean;
24
+ readonly connecting = false;
25
+ readonly pending = false;
26
+ bytesRead = 0;
27
+ bytesWritten = 0;
28
+
29
+ // Bridge response we forward pause/resume to (via super.pause/resume
30
+ // for now; the bridge will grow explicit pause/unpause hooks later).
31
+ private readonly _bridgeRes: BridgeResponse;
32
+ private _bridgePaused = false;
33
+
34
+ constructor(
35
+ remoteAddress: string,
36
+ remotePort: number,
37
+ localAddress: string,
38
+ localPort: number,
39
+ bridgeRes: BridgeResponse,
40
+ encrypted = false,
41
+ ) {
42
+ super({ allowHalfOpen: true });
43
+ this.remoteAddress = remoteAddress;
44
+ this.remotePort = remotePort;
45
+ this.localAddress = localAddress;
46
+ this.localPort = localPort;
47
+ this.encrypted = encrypted;
48
+ this._bridgeRes = bridgeRes;
49
+ }
50
+
51
+ pause(): this {
52
+ if (this._bridgePaused) return this;
53
+ this._bridgePaused = true;
54
+ return super.pause() as this;
55
+ }
56
+
57
+ resume(): this {
58
+ if (!this._bridgePaused) return super.resume() as this;
59
+ this._bridgePaused = false;
60
+ return super.resume() as this;
61
+ }
62
+
63
+ // The bridge owns the TCP connection — no data flows through this Duplex.
64
+ _read(_size: number): void {}
65
+ _write(_chunk: unknown, _encoding: BufferEncoding, cb: (err?: Error | null) => void): void { cb(); }
66
+
67
+ destroySoon(): void {
68
+ if (!this.writableEnded) this.end();
69
+ if (this.writableFinished)
70
+ this.destroy();
71
+ else
72
+ this.once('finish', () => this.destroy());
73
+ }
74
+
75
+ setTimeout(_timeout: number, cb?: () => void): this {
76
+ if (cb) this.once('timeout', cb);
77
+ return this;
78
+ }
79
+
80
+ setNoDelay(_noDelay?: boolean): this { return this; }
81
+ setKeepAlive(_enable?: boolean, _delay?: number): this { return this; }
82
+ ref(): this { return this; }
83
+ unref(): this { return this; }
84
+
85
+ address(): { address: string; family: string; port: number } {
86
+ return { address: this.localAddress, family: 'IPv4', port: this.localPort };
87
+ }
88
+ }
package/src/server.ts CHANGED
@@ -1,13 +1,28 @@
1
1
  // Reference: Node.js lib/_http_server.js
2
- // Reimplemented for GJS using Soup.Server
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`.
3
13
 
4
- import Soup from '@girs/soup-3.0';
5
14
  import Gio from '@girs/gio-2.0';
6
15
  import { EventEmitter } from 'node:events';
7
16
  import { Writable } from 'node:stream';
8
17
  import { Buffer } from 'node:buffer';
9
18
  import { Socket as NetSocket } from '@gjsify/net/socket';
10
- import { deferEmit, ensureMainLoop } from '@gjsify/utils';
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';
11
26
  import { STATUS_CODES } from './constants.js';
12
27
  import { IncomingMessage } from './incoming-message.js';
13
28
 
@@ -92,19 +107,27 @@ export class OutgoingMessage extends Writable {
92
107
 
93
108
  /**
94
109
  * ServerResponse — Writable stream representing an HTTP response.
95
- * Extends OutgoingMessage for shared header management.
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).
96
114
  */
97
115
  export class ServerResponse extends OutgoingMessage {
98
116
  statusCode = 200;
99
117
  statusMessage = '';
100
118
 
101
119
  private _streaming = false;
102
- private _soupMsg: Soup.ServerMessage;
120
+ private _bridge: BridgeResponse;
103
121
  private _timeoutTimer: ReturnType<typeof setTimeout> | null = null;
104
122
 
105
- constructor(soupMsg: Soup.ServerMessage) {
123
+ constructor(bridge: BridgeResponse) {
106
124
  super();
107
- this._soupMsg = soupMsg;
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
+ });
108
131
  }
109
132
 
110
133
  /** Set a timeout for the response. Emits 'timeout' if response not sent within msecs. */
@@ -156,8 +179,6 @@ export class ServerResponse extends OutgoingMessage {
156
179
 
157
180
  /** Flush headers (send them immediately). */
158
181
  flushHeaders(): void {
159
- // In our Soup-based implementation, headers are sent with the body.
160
- // This is a no-op but marks headersSent for compatibility.
161
182
  if (!this.headersSent) {
162
183
  this.headersSent = true;
163
184
  }
@@ -165,17 +186,16 @@ export class ServerResponse extends OutgoingMessage {
165
186
 
166
187
  /** Add trailing headers for chunked transfer encoding. */
167
188
  addTrailers(headers: Record<string, string>): void {
168
- // Soup.Server doesn't support HTTP trailers natively.
169
- // Store for compatibility but they won't be sent.
189
+ // Soup.Server doesn't support HTTP trailers natively. Stored for
190
+ // compatibility but not transmitted.
170
191
  for (const [key, value] of Object.entries(headers)) {
171
- // Trailers are appended after the body in chunked encoding
172
192
  this._headers.set('trailer-' + key.toLowerCase(), value);
173
193
  }
174
194
  }
175
195
 
176
196
  /**
177
- * Send status + headers to the client via Soup and switch to streaming (chunked) mode.
178
- * Called on the first write() — subsequent writes append chunks and unpause.
197
+ * Push our header map to the bridge and call write_head() on first
198
+ * write. Idempotent.
179
199
  */
180
200
  private _startStreaming(): void {
181
201
  if (this._streaming) return;
@@ -187,83 +207,42 @@ export class ServerResponse extends OutgoingMessage {
187
207
  this._timeoutTimer = null;
188
208
  }
189
209
 
190
- this._soupMsg.set_status(this.statusCode, this.statusMessage || null);
191
-
192
- const responseHeaders = this._soupMsg.get_response_headers();
193
- responseHeaders.set_encoding(Soup.Encoding.CHUNKED);
194
-
195
- if (!this._headers.has('connection')) {
196
- responseHeaders.replace('Connection', 'close');
197
- }
198
-
199
210
  for (const [key, value] of this._headers) {
200
211
  if (Array.isArray(value)) {
201
- for (const v of value) {
202
- responseHeaders.append(key, v);
203
- }
212
+ for (const v of value) this._bridge.append_header(key, v);
204
213
  } else {
205
- responseHeaders.replace(key, value as string);
214
+ this._bridge.set_header(key, value as string);
206
215
  }
207
216
  }
217
+
218
+ this._bridge.write_head(this.statusCode, this.statusMessage || null);
208
219
  }
209
220
 
210
- /** Writable stream _write — sends headers on first call, then appends + flushes each chunk. */
221
+ /** Writable stream _write — sends headers on first call, then appends each chunk. */
211
222
  _write(chunk: any, encoding: string, callback: (error?: Error | null) => void): void {
212
223
  const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding as BufferEncoding);
213
224
  this._startStreaming();
214
- const responseBody = this._soupMsg.get_response_body();
215
- // GJS overload: append(data: Uint8Array) — single argument, no MemoryUse parameter
216
- responseBody.append(new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength));
217
- this._soupMsg.unpause();
225
+ this._bridge.write_chunk(new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength));
218
226
  callback();
219
227
  }
220
228
 
221
- /** Called by Writable.end() — completes the body (streaming) or sends batch response (no-body). */
229
+ /** Called by Writable.end() — finishes the bridge response. */
222
230
  _final(callback: (error?: Error | null) => void): void {
223
- if (this._streaming) {
224
- // Streaming mode — signal no more chunks
225
- const responseBody = this._soupMsg.get_response_body();
226
- responseBody.complete();
227
- this._soupMsg.unpause();
228
- } else {
229
- // Batch mode — no write() was called (e.g. redirects, 204, empty end())
230
- this._sendBatchResponse();
231
- }
232
- this.finished = true;
233
- callback();
234
- }
235
-
236
- /** Batch response — sends status + headers + empty/no body in one shot (for responses without write()). */
237
- private _sendBatchResponse(): void {
238
- if (this.headersSent) return;
239
- this.headersSent = true;
240
-
241
- if (this._timeoutTimer) {
242
- clearTimeout(this._timeoutTimer);
243
- this._timeoutTimer = null;
244
- }
245
-
246
- this._soupMsg.set_status(this.statusCode, this.statusMessage || null);
247
-
248
- const responseHeaders = this._soupMsg.get_response_headers();
249
-
250
- if (!this._headers.has('connection')) {
251
- responseHeaders.replace('Connection', 'close');
252
- }
253
-
254
- for (const [key, value] of this._headers) {
255
- if (Array.isArray(value)) {
256
- for (const v of value) {
257
- responseHeaders.append(key, v);
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);
258
239
  }
259
- } else {
260
- responseHeaders.replace(key, value as string);
261
240
  }
241
+ this._bridge.write_head(this.statusCode, this.statusMessage || null);
262
242
  }
263
-
264
- // Empty body — use set_response so Soup knows the response is complete.
265
- const contentType = (this._headers.get('content-type') as string) || 'text/plain';
266
- this._soupMsg.set_response(contentType, Soup.MemoryUse.COPY, new Uint8Array(0));
243
+ this._bridge.end();
244
+ this.finished = true;
245
+ callback();
267
246
  }
268
247
 
269
248
  /** Write status + headers + body in one call (convenience). */
@@ -285,14 +264,20 @@ export class ServerResponse extends OutgoingMessage {
285
264
  }
286
265
  }
287
266
 
288
- // GC guard — GJS garbage-collects objects with no JS references. When frameworks
289
- // like Koa/Express create an http.Server inside .listen() and the caller discards
290
- // the return value, the Server (and its Soup.Server) gets collected after ~10s.
291
- // This Set keeps a strong reference to every listening server.
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.
292
272
  const _activeServers = new Set<Server>();
293
273
 
294
274
  /**
295
- * HTTP Server wrapping Soup.Server.
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.
296
281
  */
297
282
  export class Server extends EventEmitter {
298
283
  listening = false;
@@ -302,9 +287,16 @@ export class Server extends EventEmitter {
302
287
  headersTimeout = 60000;
303
288
  requestTimeout = 300000;
304
289
 
305
- private _soupServer: Soup.Server | null = null;
290
+ private _bridge: BridgeServer | null = null;
306
291
  private _address: { port: number; family: string; address: string } | null = null;
307
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
+
308
300
  constructor(requestListener?: ((req: IncomingMessage, res: ServerResponse) => void) | Record<string, unknown>);
309
301
  constructor(options: Record<string, unknown>, requestListener?: (req: IncomingMessage, res: ServerResponse) => void);
310
302
  constructor(
@@ -312,11 +304,8 @@ export class Server extends EventEmitter {
312
304
  requestListener?: (req: IncomingMessage, res: ServerResponse) => void,
313
305
  ) {
314
306
  super();
315
- // Support Node.js signature: new Server(options, listener)
316
307
  const listener = typeof optionsOrListener === 'function' ? optionsOrListener : requestListener;
317
- if (listener) {
318
- this.on('request', listener);
319
- }
308
+ if (listener) this.on('request', listener);
320
309
  }
321
310
 
322
311
  listen(port?: number, hostname?: string, backlog?: number, callback?: () => void): this;
@@ -336,110 +325,127 @@ export class Server extends EventEmitter {
336
325
  if (callback) this.once('listening', callback);
337
326
 
338
327
  try {
339
- this._soupServer = new Soup.Server({});
328
+ this._bridge = new BridgeServer();
340
329
 
341
- // Add a catch-all handler
342
- this._soupServer.add_handler(null, (server: Soup.Server, msg: Soup.ServerMessage, path: string) => {
343
- this._handleRequest(msg, path);
330
+ this._bridge.connect('request-received', (_self: BridgeServer, req: BridgeRequest, res: BridgeResponse) => {
331
+ this._handleRequest(req, res);
344
332
  });
345
333
 
346
- this._soupServer.listen_local(port, Soup.ServerListenOptions.IPV4_ONLY);
347
- ensureMainLoop();
334
+ this._bridge.connect('upgrade', (_self: BridgeServer, req: BridgeRequest, iostream: Gio.IOStream, _head: unknown) => {
335
+ this._handleUpgrade(req, iostream);
336
+ });
348
337
 
349
- // Get the actual port from listeners
350
- const listeners = this._soupServer.get_listeners();
351
- let actualPort = port;
352
- if (listeners && listeners.length > 0) {
353
- const addr = listeners[0].get_local_address() as Gio.InetSocketAddress;
354
- if (addr && typeof addr.get_port === 'function') {
355
- actualPort = addr.get_port();
356
- }
357
- }
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();
358
345
 
359
346
  this.listening = true;
360
- this._address = { port: actualPort, family: 'IPv4', address: hostname };
347
+ this._address = { port: this._bridge.port, family: 'IPv4', address: this._bridge.address || hostname };
361
348
  _activeServers.add(this);
362
-
363
349
  deferEmit(this, 'listening');
364
350
  } catch (err: unknown) {
365
- const error = err instanceof Error ? err : new Error(String(err));
366
- if (this.listenerCount('error') === 0) {
367
- // No error listener — throw like Node.js does for unhandled EventEmitter errors
368
- throw error;
369
- }
370
- deferEmit(this, 'error', error);
351
+ const nodeErr = createNodeError(err, 'listen', { address: hostname, port });
352
+ deferEmit(this, 'error', nodeErr);
371
353
  }
372
354
 
373
355
  return this;
374
356
  }
375
357
 
376
- private _handleRequest(soupMsg: Soup.ServerMessage, path: string): void {
358
+ private _handleRequest(bridgeReq: BridgeRequest, bridgeRes: BridgeResponse): void {
377
359
  const req = new IncomingMessage();
378
- const res = new ServerResponse(soupMsg);
360
+ const res = new ServerResponse(bridgeRes);
379
361
 
380
- // Populate request properties
381
- req.method = soupMsg.get_method();
382
- req.url = soupMsg.get_uri().get_path();
383
- const query = soupMsg.get_uri().get_query();
384
- if (query) req.url += '?' + query;
362
+ req.method = bridgeReq.method;
363
+ req.url = bridgeReq.url;
385
364
  req.httpVersion = '1.1';
386
365
 
387
- // Parse headers
388
- const requestHeaders = soupMsg.get_request_headers();
389
- requestHeaders.foreach((name: string, value: string) => {
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];
390
371
  const lower = name.toLowerCase();
391
372
  req.rawHeaders.push(name, value);
392
373
  if (lower in req.headers) {
393
374
  const existing = req.headers[lower];
394
- if (Array.isArray(existing)) {
395
- existing.push(value);
396
- } else {
397
- req.headers[lower] = [existing as string, value];
398
- }
375
+ if (Array.isArray(existing)) existing.push(value);
376
+ else req.headers[lower] = [existing as string, value];
399
377
  } else {
400
378
  req.headers[lower] = value;
401
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');
402
406
  });
403
407
 
404
- // Check for HTTP upgrade request (WebSocket, etc.)
405
- // Reference: Node.js lib/_http_server.js emits 'upgrade' with (req, socket, head)
406
- const connectionHeader = (req.headers['connection'] as string || '').toLowerCase();
407
- const upgradeHeader = (req.headers['upgrade'] as string || '').toLowerCase();
408
- if (connectionHeader.includes('upgrade') && upgradeHeader && this.listenerCount('upgrade') > 0) {
409
- // Steal the raw TCP connection from Soup before it sends a response.
410
- // This gives us a Gio.IOStream positioned after the parsed HTTP request.
411
- let ioStream: Gio.IOStream | null = null;
412
- try {
413
- ioStream = soupMsg.steal_connection();
414
- } catch (err) {
415
- // steal_connection() may fail if Soup has already started processing
416
- // the response or if the connection is in an unexpected state.
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
+ });
417
420
  }
418
- if (ioStream) {
419
- const socket = new NetSocket();
420
- socket._setupFromIOStream(ioStream);
421
- // head: any data after HTTP headers empty for upgrade requests
422
- this.emit('upgrade', req, socket, Buffer.alloc(0));
423
- return;
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 */ }
424
425
  }
425
426
  }
427
+ }
426
428
 
427
- // Get request body
428
- const body = soupMsg.get_request_body();
429
- if (body && body.data && body.data.length > 0) {
430
- req._pushBody(body.data);
431
- } else {
432
- req._pushBody(null);
433
- }
434
-
435
- // Pause Soup's processing — we'll set the response when ServerResponse.end() is called
436
- soupMsg.pause();
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';
437
434
 
438
- res.on('finish', () => {
439
- soupMsg.unpause();
440
- });
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
+ }
441
443
 
442
- this.emit('request', req, res);
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
+ }
443
449
  }
444
450
 
445
451
  address(): { port: number; family: string; address: string } | null {
@@ -448,7 +454,7 @@ export class Server extends EventEmitter {
448
454
 
449
455
  /**
450
456
  * Register a WebSocket handler on this server (GJS only).
451
- * Delegates to Soup.Server.add_websocket_handler().
457
+ * Delegates to the underlying `Soup.Server.add_websocket_handler()`.
452
458
  * @param path URL path to handle WebSocket upgrades (e.g., '/ws')
453
459
  * @param callback Called for each new WebSocket connection with the Soup.WebsocketConnection
454
460
  */
@@ -456,12 +462,20 @@ export class Server extends EventEmitter {
456
462
  path: string,
457
463
  callback: (connection: unknown) => void,
458
464
  ): void {
459
- if (!this._soupServer) {
465
+ if (!this._bridge) {
460
466
  throw new Error('Server must be listening before adding WebSocket handlers. Call listen() first.');
461
467
  }
462
- this._soupServer.add_websocket_handler(
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(
463
477
  path, null, null,
464
- (_srv: Soup.Server, _msg: Soup.ServerMessage, _path: string, connection: unknown) => {
478
+ (_srv, _msg, _p, connection) => {
465
479
  callback(connection);
466
480
  },
467
481
  );
@@ -470,9 +484,9 @@ export class Server extends EventEmitter {
470
484
  close(callback?: (err?: Error) => void): this {
471
485
  if (callback) this.once('close', callback);
472
486
 
473
- if (this._soupServer) {
474
- this._soupServer.disconnect();
475
- this._soupServer = null;
487
+ if (this._bridge) {
488
+ this._bridge.close();
489
+ this._bridge = null;
476
490
  }
477
491
 
478
492
  this.listening = false;
package/src/test.mts CHANGED
@@ -8,5 +8,6 @@ import extendedTestSuite from './extended.spec.js';
8
8
  import streamingTestSuite from './streaming.spec.js';
9
9
  import timeoutTestSuite from './timeout.spec.js';
10
10
  import upgradeTestSuite from './upgrade.spec.js';
11
+ import listenErrorTestSuite from './listen-error.spec.js';
11
12
 
12
- run({testSuite, clientTestSuite, extendedTestSuite, streamingTestSuite, timeoutTestSuite, upgradeTestSuite});
13
+ run({testSuite, clientTestSuite, extendedTestSuite, streamingTestSuite, timeoutTestSuite, upgradeTestSuite, listenErrorTestSuite});