@gjsify/http 0.1.13 → 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.
- package/lib/esm/incoming-message.js +7 -0
- package/lib/esm/server-request-socket.js +71 -0
- package/lib/esm/server.js +126 -117
- package/lib/types/incoming-message.d.ts +3 -0
- package/lib/types/listen-error.spec.d.ts +2 -0
- package/lib/types/server-request-socket.d.ts +32 -0
- package/lib/types/server.d.ts +24 -13
- package/package.json +15 -14
- package/src/incoming-message.ts +6 -0
- package/src/listen-error.spec.ts +73 -0
- package/src/server-request-socket.ts +88 -0
- package/src/server.ts +176 -162
- package/src/test.mts +2 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -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
|
|
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 {
|
|
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
|
-
*
|
|
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
|
|
120
|
+
private _bridge: BridgeResponse;
|
|
103
121
|
private _timeoutTimer: ReturnType<typeof setTimeout> | null = null;
|
|
104
122
|
|
|
105
|
-
constructor(
|
|
123
|
+
constructor(bridge: BridgeResponse) {
|
|
106
124
|
super();
|
|
107
|
-
this.
|
|
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
|
-
//
|
|
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
|
-
*
|
|
178
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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() —
|
|
229
|
+
/** Called by Writable.end() — finishes the bridge response. */
|
|
222
230
|
_final(callback: (error?: Error | null) => void): void {
|
|
223
|
-
if (this._streaming) {
|
|
224
|
-
//
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
265
|
-
|
|
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
|
|
289
|
-
// like Koa/Express create an http.Server inside .listen() and
|
|
290
|
-
// the return value, the Server (and its
|
|
291
|
-
// This Set keeps a strong
|
|
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
|
|
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
|
|
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.
|
|
328
|
+
this._bridge = new BridgeServer();
|
|
340
329
|
|
|
341
|
-
|
|
342
|
-
|
|
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.
|
|
347
|
-
|
|
334
|
+
this._bridge.connect('upgrade', (_self: BridgeServer, req: BridgeRequest, iostream: Gio.IOStream, _head: unknown) => {
|
|
335
|
+
this._handleUpgrade(req, iostream);
|
|
336
|
+
});
|
|
348
337
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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:
|
|
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
|
|
366
|
-
|
|
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(
|
|
358
|
+
private _handleRequest(bridgeReq: BridgeRequest, bridgeRes: BridgeResponse): void {
|
|
377
359
|
const req = new IncomingMessage();
|
|
378
|
-
const res = new ServerResponse(
|
|
360
|
+
const res = new ServerResponse(bridgeRes);
|
|
379
361
|
|
|
380
|
-
|
|
381
|
-
req.
|
|
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
|
-
//
|
|
388
|
-
const
|
|
389
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
405
|
-
//
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
428
|
-
const
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
439
|
-
|
|
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.
|
|
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.
|
|
465
|
+
if (!this._bridge) {
|
|
460
466
|
throw new Error('Server must be listening before adding WebSocket handlers. Call listen() first.');
|
|
461
467
|
}
|
|
462
|
-
this.
|
|
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
|
|
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.
|
|
474
|
-
this.
|
|
475
|
-
this.
|
|
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});
|