@gjsify/net 0.0.3 → 0.1.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/README.md +29 -1
- package/lib/esm/index.js +34 -3
- package/lib/esm/server.js +133 -0
- package/lib/esm/socket.js +332 -0
- package/lib/types/index.d.ts +30 -0
- package/lib/types/server.d.ts +40 -0
- package/lib/types/socket.d.ts +78 -0
- package/package.json +23 -22
- package/src/error.spec.ts +169 -0
- package/src/extended.spec.ts +413 -0
- package/src/index.spec.ts +1072 -80
- package/src/index.ts +47 -12
- package/src/server.spec.ts +303 -0
- package/src/server.ts +186 -0
- package/src/socket.ts +404 -0
- package/src/test.mts +6 -2
- package/src/timeout.spec.ts +464 -0
- package/tsconfig.json +22 -9
- package/tsconfig.tsbuildinfo +1 -0
- package/lib/cjs/index.js +0 -26
- package/test.gjs.mjs +0 -34801
- package/test.node.mjs +0 -366
- package/tsconfig.types.json +0 -8
package/src/socket.ts
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
// Reference: Node.js lib/net.js, refs/deno/ext/node/polyfills/net.ts
|
|
2
|
+
// Reimplemented for GJS using Gio.SocketClient / Gio.SocketConnection
|
|
3
|
+
|
|
4
|
+
import Gio from '@girs/gio-2.0';
|
|
5
|
+
import GLib from '@girs/glib-2.0';
|
|
6
|
+
import { Duplex } from 'node:stream';
|
|
7
|
+
import { Buffer } from 'node:buffer';
|
|
8
|
+
import { createNodeError, gbytesToUint8Array } from '@gjsify/utils';
|
|
9
|
+
import type { DuplexOptions } from 'node:stream';
|
|
10
|
+
|
|
11
|
+
export interface SocketConnectOptions {
|
|
12
|
+
port: number;
|
|
13
|
+
host?: string;
|
|
14
|
+
localAddress?: string;
|
|
15
|
+
localPort?: number;
|
|
16
|
+
family?: 4 | 6 | 0;
|
|
17
|
+
keepAlive?: boolean;
|
|
18
|
+
keepAliveInitialDelay?: number;
|
|
19
|
+
noDelay?: boolean;
|
|
20
|
+
timeout?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SocketOptions extends DuplexOptions {
|
|
24
|
+
allowHalfOpen?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class Socket extends Duplex {
|
|
28
|
+
// Public properties matching Node.js net.Socket
|
|
29
|
+
remoteAddress?: string;
|
|
30
|
+
remotePort?: number;
|
|
31
|
+
remoteFamily?: string;
|
|
32
|
+
localAddress?: string;
|
|
33
|
+
localPort?: number;
|
|
34
|
+
bytesRead = 0;
|
|
35
|
+
bytesWritten = 0;
|
|
36
|
+
connecting = false;
|
|
37
|
+
destroyed = false;
|
|
38
|
+
pending = true;
|
|
39
|
+
readyState: 'opening' | 'open' | 'readOnly' | 'writeOnly' | 'closed' = 'closed';
|
|
40
|
+
declare allowHalfOpen: boolean;
|
|
41
|
+
|
|
42
|
+
private _connection: Gio.SocketConnection | null = null;
|
|
43
|
+
private _ioStream: Gio.IOStream | null = null;
|
|
44
|
+
private _inputStream: Gio.InputStream | null = null;
|
|
45
|
+
private _outputStream: Gio.OutputStream | null = null;
|
|
46
|
+
private _cancellable: Gio.Cancellable = new Gio.Cancellable();
|
|
47
|
+
private _reading = false;
|
|
48
|
+
private _timeout = 0;
|
|
49
|
+
private _timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
50
|
+
|
|
51
|
+
constructor(options?: SocketOptions) {
|
|
52
|
+
super(options);
|
|
53
|
+
this.allowHalfOpen = options?.allowHalfOpen ?? false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** @internal Set the connection from an accepted server socket. */
|
|
57
|
+
_setConnection(connection: Gio.SocketConnection): void {
|
|
58
|
+
this._connection = connection;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @internal Set up this socket from a raw Gio.IOStream (e.g., stolen from Soup.Server
|
|
63
|
+
* during an HTTP upgrade). Extracts I/O streams, attempts to read address info
|
|
64
|
+
* if the underlying stream is a SocketConnection, and starts reading.
|
|
65
|
+
*/
|
|
66
|
+
_setupFromIOStream(ioStream: Gio.IOStream): void {
|
|
67
|
+
this._ioStream = ioStream;
|
|
68
|
+
|
|
69
|
+
// If the IOStream is actually a SocketConnection, use it for full features
|
|
70
|
+
try {
|
|
71
|
+
const sockConn = ioStream as unknown as Gio.SocketConnection;
|
|
72
|
+
if (typeof sockConn.get_socket === 'function') {
|
|
73
|
+
this._connection = sockConn;
|
|
74
|
+
const remoteAddr = sockConn.get_remote_address() as Gio.InetSocketAddress;
|
|
75
|
+
this.remoteAddress = remoteAddr.get_address().to_string();
|
|
76
|
+
this.remotePort = remoteAddr.get_port();
|
|
77
|
+
this.remoteFamily = remoteAddr.get_address().get_family() === Gio.SocketFamily.IPV6 ? 'IPv6' : 'IPv4';
|
|
78
|
+
const localAddr = sockConn.get_local_address() as Gio.InetSocketAddress;
|
|
79
|
+
this.localAddress = localAddr.get_address().to_string();
|
|
80
|
+
this.localPort = localAddr.get_port();
|
|
81
|
+
}
|
|
82
|
+
} catch { /* not a SocketConnection — use IOStream only */ }
|
|
83
|
+
|
|
84
|
+
this._inputStream = ioStream.get_input_stream();
|
|
85
|
+
this._outputStream = ioStream.get_output_stream();
|
|
86
|
+
|
|
87
|
+
this.connecting = false;
|
|
88
|
+
this.pending = false;
|
|
89
|
+
this.readyState = 'open';
|
|
90
|
+
|
|
91
|
+
this.emit('connect');
|
|
92
|
+
this.emit('ready');
|
|
93
|
+
|
|
94
|
+
this._startReading();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Initiate a TCP connection.
|
|
99
|
+
*/
|
|
100
|
+
connect(options: SocketConnectOptions | number, host?: string | (() => void), connectionListener?: () => void): this;
|
|
101
|
+
connect(options: SocketConnectOptions | number, host?: string | (() => void), connectionListener?: () => void): this {
|
|
102
|
+
// Normalize arguments
|
|
103
|
+
let opts: SocketConnectOptions;
|
|
104
|
+
if (typeof options === 'number') {
|
|
105
|
+
opts = { port: options, host: typeof host === 'string' ? host : 'localhost' };
|
|
106
|
+
if (typeof host === 'function') connectionListener = host;
|
|
107
|
+
} else {
|
|
108
|
+
opts = options;
|
|
109
|
+
if (typeof host === 'function') connectionListener = host;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (connectionListener) {
|
|
113
|
+
this.once('connect', connectionListener);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
this.connecting = true;
|
|
117
|
+
this.readyState = 'opening';
|
|
118
|
+
this.pending = true;
|
|
119
|
+
|
|
120
|
+
const targetHost = opts.host || 'localhost';
|
|
121
|
+
const targetPort = opts.port;
|
|
122
|
+
|
|
123
|
+
const client = new Gio.SocketClient();
|
|
124
|
+
|
|
125
|
+
if (opts.timeout) {
|
|
126
|
+
client.set_timeout(Math.ceil(opts.timeout / 1000));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Async connect
|
|
130
|
+
client.connect_to_host_async(
|
|
131
|
+
targetHost,
|
|
132
|
+
targetPort,
|
|
133
|
+
this._cancellable,
|
|
134
|
+
(_source: Gio.SocketClient | null, asyncResult: Gio.AsyncResult) => {
|
|
135
|
+
try {
|
|
136
|
+
this._connection = client.connect_to_host_finish(asyncResult);
|
|
137
|
+
this._setupConnection(opts);
|
|
138
|
+
} catch (err: unknown) {
|
|
139
|
+
this.connecting = false;
|
|
140
|
+
this.readyState = 'closed';
|
|
141
|
+
const nodeErr = createNodeError(err, 'connect', {
|
|
142
|
+
address: targetHost,
|
|
143
|
+
port: targetPort,
|
|
144
|
+
});
|
|
145
|
+
this.destroy(nodeErr);
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
return this;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** @internal Set up streams and emit connect after connection is established. */
|
|
154
|
+
_setupConnection(opts: SocketConnectOptions | Record<string, never>): void {
|
|
155
|
+
if (!this._connection) return;
|
|
156
|
+
|
|
157
|
+
const sock = this._connection.get_socket();
|
|
158
|
+
this._inputStream = this._connection.get_input_stream();
|
|
159
|
+
this._outputStream = this._connection.get_output_stream();
|
|
160
|
+
|
|
161
|
+
// Set socket options
|
|
162
|
+
if ('keepAlive' in opts && opts.keepAlive) {
|
|
163
|
+
sock.set_keepalive(true);
|
|
164
|
+
}
|
|
165
|
+
if (!('noDelay' in opts) || opts.noDelay !== false) {
|
|
166
|
+
// TCP_NODELAY: level=6 (IPPROTO_TCP), optname=1 (TCP_NODELAY)
|
|
167
|
+
try { sock.set_option(6, 1, 1); } catch { /* may not be supported */ }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Extract address info
|
|
171
|
+
try {
|
|
172
|
+
const remoteAddr = this._connection.get_remote_address() as Gio.InetSocketAddress;
|
|
173
|
+
this.remoteAddress = remoteAddr.get_address().to_string();
|
|
174
|
+
this.remotePort = remoteAddr.get_port();
|
|
175
|
+
this.remoteFamily = remoteAddr.get_address().get_family() === Gio.SocketFamily.IPV6 ? 'IPv6' : 'IPv4';
|
|
176
|
+
} catch { /* ignore */ }
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
const localAddr = this._connection.get_local_address() as Gio.InetSocketAddress;
|
|
180
|
+
this.localAddress = localAddr.get_address().to_string();
|
|
181
|
+
this.localPort = localAddr.get_port();
|
|
182
|
+
} catch { /* ignore */ }
|
|
183
|
+
|
|
184
|
+
this.connecting = false;
|
|
185
|
+
this.pending = false;
|
|
186
|
+
this.readyState = 'open';
|
|
187
|
+
|
|
188
|
+
this.emit('connect');
|
|
189
|
+
this.emit('ready');
|
|
190
|
+
|
|
191
|
+
// Start reading
|
|
192
|
+
this._startReading();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private _startReading(): void {
|
|
196
|
+
if (this._reading || !this._inputStream) return;
|
|
197
|
+
this._reading = true;
|
|
198
|
+
this._readLoop();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private async _readLoop(): Promise<void> {
|
|
202
|
+
const inputStream = this._inputStream;
|
|
203
|
+
if (!inputStream) return;
|
|
204
|
+
|
|
205
|
+
const CHUNK_SIZE = 16384;
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
while (this._reading && inputStream) {
|
|
209
|
+
const bytes = await new Promise<GLib.Bytes | null>((resolve, reject) => {
|
|
210
|
+
inputStream.read_bytes_async(
|
|
211
|
+
CHUNK_SIZE,
|
|
212
|
+
GLib.PRIORITY_DEFAULT,
|
|
213
|
+
this._cancellable,
|
|
214
|
+
(_source: Gio.InputStream | null, asyncResult: Gio.AsyncResult) => {
|
|
215
|
+
try {
|
|
216
|
+
resolve(inputStream.read_bytes_finish(asyncResult));
|
|
217
|
+
} catch (err) {
|
|
218
|
+
reject(err);
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
);
|
|
222
|
+
});
|
|
223
|
+
if (!bytes || bytes.get_size() === 0) {
|
|
224
|
+
// EOF — remote peer closed their write side
|
|
225
|
+
this._reading = false;
|
|
226
|
+
if (!this.allowHalfOpen) {
|
|
227
|
+
// Default: close our write side after 'end' listeners have run,
|
|
228
|
+
// so they can still write before the socket shuts down.
|
|
229
|
+
this.once('end', () => {
|
|
230
|
+
this.end();
|
|
231
|
+
this.readyState = 'closed';
|
|
232
|
+
});
|
|
233
|
+
} else {
|
|
234
|
+
this.readyState = this.writable ? 'writeOnly' : 'closed';
|
|
235
|
+
}
|
|
236
|
+
this.push(null);
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const data = gbytesToUint8Array(bytes);
|
|
241
|
+
this.bytesRead += data.length;
|
|
242
|
+
this._resetTimeout();
|
|
243
|
+
|
|
244
|
+
if (!this.push(Buffer.from(data))) {
|
|
245
|
+
// Backpressure — pause reading until _read is called
|
|
246
|
+
this._reading = false;
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
} catch (err: unknown) {
|
|
251
|
+
if (!this._cancellable.is_cancelled()) {
|
|
252
|
+
this.destroy(createNodeError(err, 'read', { address: this.remoteAddress }));
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Duplex stream interface
|
|
258
|
+
_read(_size: number): void {
|
|
259
|
+
if (!this._reading && this._inputStream) {
|
|
260
|
+
this._startReading();
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
_write(chunk: any, encoding: string, callback: (error?: Error | null) => void): void {
|
|
265
|
+
if (!this._outputStream) {
|
|
266
|
+
callback(new Error('Socket is not connected'));
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding as BufferEncoding);
|
|
271
|
+
|
|
272
|
+
this._outputStream.write_bytes_async(
|
|
273
|
+
new GLib.Bytes(data),
|
|
274
|
+
GLib.PRIORITY_DEFAULT,
|
|
275
|
+
this._cancellable,
|
|
276
|
+
(_source: Gio.OutputStream | null, asyncResult: Gio.AsyncResult) => {
|
|
277
|
+
try {
|
|
278
|
+
const written = this._outputStream!.write_bytes_finish(asyncResult);
|
|
279
|
+
this.bytesWritten += written;
|
|
280
|
+
this._resetTimeout();
|
|
281
|
+
callback(null);
|
|
282
|
+
} catch (err: unknown) {
|
|
283
|
+
callback(createNodeError(err, 'write', { address: this.remoteAddress }));
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
_final(callback: (error?: Error | null) => void): void {
|
|
290
|
+
// Half-close: shutdown write side
|
|
291
|
+
if (this._connection) {
|
|
292
|
+
try {
|
|
293
|
+
this._connection.get_socket().shutdown(false, true);
|
|
294
|
+
this.readyState = this.readable ? 'readOnly' : 'closed';
|
|
295
|
+
} catch { /* ignore */ }
|
|
296
|
+
} else if (this._ioStream) {
|
|
297
|
+
// Fallback for IOStream-based sockets (e.g., stolen from Soup.Server).
|
|
298
|
+
// SoupIOStream doesn't support half-close via output stream close,
|
|
299
|
+
// so close the entire IOStream to send TCP FIN to the peer.
|
|
300
|
+
try {
|
|
301
|
+
this._ioStream.close(null);
|
|
302
|
+
this.readyState = 'closed';
|
|
303
|
+
} catch { /* ignore */ }
|
|
304
|
+
this._ioStream = null;
|
|
305
|
+
this._inputStream = null;
|
|
306
|
+
this._outputStream = null;
|
|
307
|
+
}
|
|
308
|
+
callback();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
_destroy(err: Error | null, callback: (error?: Error | null) => void): void {
|
|
312
|
+
this._reading = false;
|
|
313
|
+
this._clearTimeout();
|
|
314
|
+
this._cancellable.cancel();
|
|
315
|
+
|
|
316
|
+
if (this._connection) {
|
|
317
|
+
try {
|
|
318
|
+
this._connection.close(null);
|
|
319
|
+
} catch { /* ignore */ }
|
|
320
|
+
this._connection = null;
|
|
321
|
+
} else if (this._ioStream) {
|
|
322
|
+
try {
|
|
323
|
+
this._ioStream.close(null);
|
|
324
|
+
} catch { /* ignore */ }
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
this._ioStream = null;
|
|
328
|
+
this._inputStream = null;
|
|
329
|
+
this._outputStream = null;
|
|
330
|
+
this.readyState = 'closed';
|
|
331
|
+
this.destroyed = true;
|
|
332
|
+
|
|
333
|
+
callback(err);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Set the socket timeout in milliseconds. 0 disables. */
|
|
337
|
+
setTimeout(timeout: number, callback?: () => void): this {
|
|
338
|
+
this._timeout = timeout;
|
|
339
|
+
this._clearTimeout();
|
|
340
|
+
|
|
341
|
+
if (callback) {
|
|
342
|
+
this.once('timeout', callback);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (timeout > 0) {
|
|
346
|
+
this._resetTimeout();
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return this;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private _resetTimeout(): void {
|
|
353
|
+
this._clearTimeout();
|
|
354
|
+
if (this._timeout > 0) {
|
|
355
|
+
this._timeoutId = setTimeout(() => {
|
|
356
|
+
this.emit('timeout');
|
|
357
|
+
}, this._timeout);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
private _clearTimeout(): void {
|
|
362
|
+
if (this._timeoutId !== null) {
|
|
363
|
+
clearTimeout(this._timeoutId);
|
|
364
|
+
this._timeoutId = null;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Enable/disable TCP keep-alive. */
|
|
369
|
+
setKeepAlive(enable?: boolean, _initialDelay?: number): this {
|
|
370
|
+
if (this._connection) {
|
|
371
|
+
try {
|
|
372
|
+
this._connection.get_socket().set_keepalive(enable ?? false);
|
|
373
|
+
} catch { /* ignore */ }
|
|
374
|
+
}
|
|
375
|
+
return this;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Enable/disable TCP_NODELAY (disable Nagle algorithm). */
|
|
379
|
+
setNoDelay(noDelay?: boolean): this {
|
|
380
|
+
if (this._connection) {
|
|
381
|
+
try {
|
|
382
|
+
this._connection.get_socket().set_option(6, 1, noDelay !== false ? 1 : 0);
|
|
383
|
+
} catch { /* ignore */ }
|
|
384
|
+
}
|
|
385
|
+
return this;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Get the underlying socket address info. */
|
|
389
|
+
address(): { port: number; family: string; address: string } | {} {
|
|
390
|
+
if (this.localAddress && this.localPort) {
|
|
391
|
+
return {
|
|
392
|
+
port: this.localPort,
|
|
393
|
+
family: this.remoteFamily || 'IPv4',
|
|
394
|
+
address: this.localAddress,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
return {};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Reference this socket (keep event loop alive). No-op in GJS. */
|
|
401
|
+
ref(): this { return this; }
|
|
402
|
+
/** Unreference this socket. No-op in GJS. */
|
|
403
|
+
unref(): this { return this; }
|
|
404
|
+
}
|
package/src/test.mts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
|
|
2
2
|
import { run } from '@gjsify/unit';
|
|
3
3
|
|
|
4
|
-
import
|
|
4
|
+
import testSuiteNet from './index.spec.js';
|
|
5
|
+
import extendedTestSuite from './extended.spec.js';
|
|
6
|
+
import serverTestSuite from './server.spec.js';
|
|
7
|
+
import timeoutTestSuite from './timeout.spec.js';
|
|
8
|
+
import errorTestSuite from './error.spec.js';
|
|
5
9
|
|
|
6
|
-
run({
|
|
10
|
+
run({ testSuiteNet, extendedTestSuite, serverTestSuite, timeoutTestSuite, errorTestSuite });
|