@gjsify/http 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 +30 -1
- package/lib/esm/client-request.js +228 -0
- package/lib/esm/constants.js +105 -0
- package/lib/esm/incoming-message.js +59 -0
- package/lib/esm/index.js +112 -2
- package/lib/esm/server.js +371 -0
- package/lib/types/client-request.d.ts +65 -0
- package/lib/types/constants.d.ts +2 -0
- package/lib/types/incoming-message.d.ts +25 -0
- package/lib/types/index.d.ts +102 -0
- package/lib/types/server.d.ts +102 -0
- package/package.json +24 -18
- package/src/client-request.ts +307 -0
- package/src/client.spec.ts +538 -0
- package/src/constants.ts +33 -0
- package/src/extended.spec.ts +620 -0
- package/src/incoming-message.ts +71 -0
- package/src/index.spec.ts +1359 -67
- package/src/index.ts +164 -20
- package/src/server.ts +489 -0
- package/src/streaming.spec.ts +588 -0
- package/src/test.mts +6 -1
- package/src/timeout.spec.ts +668 -0
- package/src/upgrade.spec.ts +256 -0
- package/tsconfig.json +23 -10
- package/tsconfig.tsbuildinfo +1 -0
- package/lib/cjs/index.js +0 -18
- package/test.gjs.js +0 -34832
- package/test.gjs.mjs +0 -34786
- package/test.gjs.mjs.meta.json +0 -1
- package/test.node.js +0 -1278
- package/test.node.mjs +0 -358
- package/tsconfig.types.json +0 -8
package/package.json
CHANGED
|
@@ -1,32 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gjsify/http",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "Node.js http module for Gjs",
|
|
5
|
-
"main": "lib/cjs/index.js",
|
|
6
5
|
"module": "lib/esm/index.js",
|
|
6
|
+
"types": "lib/types/index.d.ts",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"exports": {
|
|
9
9
|
".": {
|
|
10
|
-
"
|
|
11
|
-
|
|
12
|
-
"default": "./lib/esm/index.js"
|
|
13
|
-
},
|
|
14
|
-
"require": {
|
|
15
|
-
"types": "./lib/types/index.d.ts",
|
|
16
|
-
"default": "./lib/cjs/index.js"
|
|
17
|
-
}
|
|
10
|
+
"types": "./lib/types/index.d.ts",
|
|
11
|
+
"default": "./lib/esm/index.js"
|
|
18
12
|
}
|
|
19
13
|
},
|
|
20
14
|
"scripts": {
|
|
21
|
-
"clear": "rm -rf lib tsconfig.tsbuildinfo tsconfig.types.tsbuildinfo || exit 0",
|
|
22
|
-
"
|
|
23
|
-
"build": "yarn
|
|
15
|
+
"clear": "rm -rf lib tsconfig.tsbuildinfo tsconfig.types.tsbuildinfo test.gjs.mjs test.node.mjs || exit 0",
|
|
16
|
+
"check": "tsc --noEmit",
|
|
17
|
+
"build": "yarn build:gjsify && yarn build:types",
|
|
24
18
|
"build:gjsify": "gjsify build --library 'src/**/*.{ts,js}' --exclude 'src/**/*.spec.{mts,ts}' 'src/test.{mts,ts}'",
|
|
25
|
-
"build:types": "tsc
|
|
19
|
+
"build:types": "tsc",
|
|
26
20
|
"build:test": "yarn build:test:gjs && yarn build:test:node",
|
|
27
21
|
"build:test:gjs": "gjsify build src/test.mts --app gjs --outfile test.gjs.mjs",
|
|
28
22
|
"build:test:node": "gjsify build src/test.mts --app node --outfile test.node.mjs",
|
|
29
|
-
"test": "yarn
|
|
23
|
+
"test": "yarn build:gjsify && yarn build:test && yarn test:node && yarn test:gjs",
|
|
30
24
|
"test:gjs": "gjs -m test.gjs.mjs",
|
|
31
25
|
"test:node": "node test.node.mjs"
|
|
32
26
|
},
|
|
@@ -36,8 +30,20 @@
|
|
|
36
30
|
"http"
|
|
37
31
|
],
|
|
38
32
|
"devDependencies": {
|
|
39
|
-
"@gjsify/cli": "^0.0
|
|
40
|
-
"@gjsify/unit": "^0.0
|
|
41
|
-
"@types/node": "^
|
|
33
|
+
"@gjsify/cli": "^0.1.0",
|
|
34
|
+
"@gjsify/unit": "^0.1.0",
|
|
35
|
+
"@types/node": "^25.5.0",
|
|
36
|
+
"typescript": "^6.0.2"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@girs/gio-2.0": "^2.88.0-4.0.0-beta.42",
|
|
40
|
+
"@girs/glib-2.0": "^2.88.0-4.0.0-beta.42",
|
|
41
|
+
"@girs/soup-3.0": "^3.6.6-4.0.0-beta.42",
|
|
42
|
+
"@gjsify/buffer": "^0.1.0",
|
|
43
|
+
"@gjsify/events": "^0.1.0",
|
|
44
|
+
"@gjsify/net": "^0.1.0",
|
|
45
|
+
"@gjsify/stream": "^0.1.0",
|
|
46
|
+
"@gjsify/url": "^0.1.0",
|
|
47
|
+
"@gjsify/utils": "^0.1.0"
|
|
42
48
|
}
|
|
43
49
|
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// ClientRequest — Writable stream for outgoing HTTP requests via Soup.Session.
|
|
2
|
+
// Reference: Node.js lib/_http_client.js, lib/_http_outgoing.js
|
|
3
|
+
|
|
4
|
+
import GLib from '@girs/glib-2.0';
|
|
5
|
+
import Soup from '@girs/soup-3.0';
|
|
6
|
+
import Gio from '@girs/gio-2.0';
|
|
7
|
+
import { Buffer } from 'node:buffer';
|
|
8
|
+
import { URL } from 'node:url';
|
|
9
|
+
import { readBytesAsync } from '@gjsify/utils';
|
|
10
|
+
import { OutgoingMessage } from './server.js';
|
|
11
|
+
import { IncomingMessage } from './incoming-message.js';
|
|
12
|
+
|
|
13
|
+
export interface ClientRequestOptions {
|
|
14
|
+
protocol?: string;
|
|
15
|
+
hostname?: string;
|
|
16
|
+
host?: string;
|
|
17
|
+
port?: number | string;
|
|
18
|
+
path?: string;
|
|
19
|
+
method?: string;
|
|
20
|
+
headers?: Record<string, string | number | string[]>;
|
|
21
|
+
timeout?: number;
|
|
22
|
+
agent?: any;
|
|
23
|
+
setHost?: boolean;
|
|
24
|
+
/** Basic authentication string in the format 'user:password'. */
|
|
25
|
+
auth?: string;
|
|
26
|
+
/** Local address to bind the request from. */
|
|
27
|
+
localAddress?: string;
|
|
28
|
+
/** IP address family (4 or 6). */
|
|
29
|
+
family?: 4 | 6 | 0;
|
|
30
|
+
/** Signal to abort the request. */
|
|
31
|
+
signal?: AbortSignal;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* ClientRequest — Writable stream representing an outgoing HTTP request.
|
|
36
|
+
*
|
|
37
|
+
* Usage:
|
|
38
|
+
* const req = http.request(options, (res) => { ... });
|
|
39
|
+
* req.write(body);
|
|
40
|
+
* req.end();
|
|
41
|
+
*/
|
|
42
|
+
export class ClientRequest extends OutgoingMessage {
|
|
43
|
+
method: string;
|
|
44
|
+
path: string;
|
|
45
|
+
protocol: string;
|
|
46
|
+
host: string;
|
|
47
|
+
hostname: string;
|
|
48
|
+
port: number;
|
|
49
|
+
aborted = false;
|
|
50
|
+
reusedSocket = false;
|
|
51
|
+
maxHeadersCount = 2000;
|
|
52
|
+
|
|
53
|
+
private _chunks: Buffer[] = [];
|
|
54
|
+
private _session: Soup.Session;
|
|
55
|
+
private _message: Soup.Message;
|
|
56
|
+
private _cancellable: Gio.Cancellable;
|
|
57
|
+
private _timeout = 0;
|
|
58
|
+
private _timeoutTimer: ReturnType<typeof setTimeout> | null = null;
|
|
59
|
+
private _responseCallback?: (res: IncomingMessage) => void;
|
|
60
|
+
|
|
61
|
+
constructor(url: string | URL | ClientRequestOptions, options?: ClientRequestOptions | ((res: IncomingMessage) => void), callback?: (res: IncomingMessage) => void) {
|
|
62
|
+
super();
|
|
63
|
+
|
|
64
|
+
// Parse arguments: request(url, options, cb) or request(options, cb)
|
|
65
|
+
let opts: ClientRequestOptions;
|
|
66
|
+
|
|
67
|
+
if (typeof url === 'string' || url instanceof URL) {
|
|
68
|
+
const parsed = typeof url === 'string' ? new URL(url) : url;
|
|
69
|
+
opts = {
|
|
70
|
+
protocol: parsed.protocol,
|
|
71
|
+
hostname: parsed.hostname,
|
|
72
|
+
port: parsed.port ? Number(parsed.port) : undefined,
|
|
73
|
+
path: parsed.pathname + parsed.search,
|
|
74
|
+
...(typeof options === 'object' ? options : {}),
|
|
75
|
+
};
|
|
76
|
+
if (typeof options === 'function') {
|
|
77
|
+
callback = options;
|
|
78
|
+
}
|
|
79
|
+
} else {
|
|
80
|
+
opts = url;
|
|
81
|
+
if (typeof options === 'function') {
|
|
82
|
+
callback = options;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
this.method = (opts.method || 'GET').toUpperCase();
|
|
87
|
+
this.protocol = opts.protocol || 'http:';
|
|
88
|
+
this.hostname = opts.hostname || opts.host?.split(':')[0] || 'localhost';
|
|
89
|
+
this.port = Number(opts.port) || (this.protocol === 'https:' ? 443 : 80);
|
|
90
|
+
this.path = opts.path || '/';
|
|
91
|
+
this.host = opts.host || `${this.hostname}:${this.port}`;
|
|
92
|
+
this._timeout = opts.timeout || 0;
|
|
93
|
+
|
|
94
|
+
if (callback) {
|
|
95
|
+
this._responseCallback = callback;
|
|
96
|
+
this.once('response', callback);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Set default headers
|
|
100
|
+
if (opts.headers) {
|
|
101
|
+
for (const [key, value] of Object.entries(opts.headers)) {
|
|
102
|
+
this.setHeader(key, value);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (opts.setHost !== false && !this._headers.has('host')) {
|
|
107
|
+
const defaultPort = this.protocol === 'https:' ? 443 : 80;
|
|
108
|
+
const hostHeader = this.port === defaultPort ? this.hostname : `${this.hostname}:${this.port}`;
|
|
109
|
+
this.setHeader('Host', hostHeader);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Basic authentication: encode user:password as Base64 Authorization header
|
|
113
|
+
if (opts.auth && !this._headers.has('authorization')) {
|
|
114
|
+
this.setHeader('Authorization', 'Basic ' + Buffer.from(opts.auth).toString('base64'));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// AbortSignal support
|
|
118
|
+
if (opts.signal) {
|
|
119
|
+
if (opts.signal.aborted) {
|
|
120
|
+
this.abort();
|
|
121
|
+
} else {
|
|
122
|
+
opts.signal.addEventListener('abort', () => this.abort(), { once: true });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Create Soup objects
|
|
127
|
+
const uri = GLib.Uri.parse(this._buildUrl(), GLib.UriFlags.NONE);
|
|
128
|
+
this._session = new Soup.Session();
|
|
129
|
+
this._message = new Soup.Message({ method: this.method, uri });
|
|
130
|
+
this._cancellable = new Gio.Cancellable();
|
|
131
|
+
|
|
132
|
+
if (this._timeout > 0) {
|
|
133
|
+
this._session.timeout = Math.ceil(this._timeout / 1000);
|
|
134
|
+
// Start timeout timer immediately for timeout option
|
|
135
|
+
this._timeoutTimer = setTimeout(() => {
|
|
136
|
+
this._timeoutTimer = null;
|
|
137
|
+
this.emit('timeout');
|
|
138
|
+
}, this._timeout);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private _buildUrl(): string {
|
|
143
|
+
const proto = this.protocol.endsWith(':') ? this.protocol : this.protocol + ':';
|
|
144
|
+
const defaultPort = proto === 'https:' ? 443 : 80;
|
|
145
|
+
const portStr = this.port === defaultPort ? '' : `:${this.port}`;
|
|
146
|
+
return `${proto}//${this.hostname}${portStr}${this.path}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Get raw header names and values as a flat array. */
|
|
150
|
+
getRawHeaderNames(): string[] {
|
|
151
|
+
return Array.from(this._headers.keys());
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Flush headers — marks headers as sent. */
|
|
155
|
+
override flushHeaders(): void {
|
|
156
|
+
if (!this.headersSent) {
|
|
157
|
+
this._applyHeaders();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Set timeout for the request. Emits 'timeout' if no response within msecs. */
|
|
162
|
+
setTimeout(msecs: number, callback?: () => void): this {
|
|
163
|
+
this._timeout = msecs;
|
|
164
|
+
if (this._timeoutTimer) {
|
|
165
|
+
clearTimeout(this._timeoutTimer);
|
|
166
|
+
this._timeoutTimer = null;
|
|
167
|
+
}
|
|
168
|
+
if (callback) this.once('timeout', callback);
|
|
169
|
+
if (msecs > 0) {
|
|
170
|
+
this._session.timeout = Math.ceil(msecs / 1000);
|
|
171
|
+
this._timeoutTimer = setTimeout(() => {
|
|
172
|
+
this._timeoutTimer = null;
|
|
173
|
+
this.emit('timeout');
|
|
174
|
+
}, msecs);
|
|
175
|
+
}
|
|
176
|
+
return this;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Abort the request. */
|
|
180
|
+
abort(): void {
|
|
181
|
+
if (this.aborted) return;
|
|
182
|
+
this.aborted = true;
|
|
183
|
+
if (this._timeoutTimer) {
|
|
184
|
+
clearTimeout(this._timeoutTimer);
|
|
185
|
+
this._timeoutTimer = null;
|
|
186
|
+
}
|
|
187
|
+
this._cancellable.cancel();
|
|
188
|
+
this.emit('abort');
|
|
189
|
+
this.destroy();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Writable stream _write implementation — collect body chunks. */
|
|
193
|
+
_write(chunk: any, encoding: string, callback: (error?: Error | null) => void): void {
|
|
194
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding as BufferEncoding);
|
|
195
|
+
this._chunks.push(buf);
|
|
196
|
+
callback();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Called when the writable stream ends — send the request. */
|
|
200
|
+
_final(callback: (error?: Error | null) => void): void {
|
|
201
|
+
this._sendRequest()
|
|
202
|
+
.then(() => callback())
|
|
203
|
+
.catch((err) => callback(err));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private _applyHeaders(): void {
|
|
207
|
+
if (this.headersSent) return;
|
|
208
|
+
this.headersSent = true;
|
|
209
|
+
|
|
210
|
+
const requestHeaders = this._message.get_request_headers();
|
|
211
|
+
for (const [key, value] of this._headers) {
|
|
212
|
+
if (Array.isArray(value)) {
|
|
213
|
+
for (const v of value) {
|
|
214
|
+
requestHeaders.append(key, v);
|
|
215
|
+
}
|
|
216
|
+
} else {
|
|
217
|
+
requestHeaders.replace(key, value as string);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private async _sendRequest(): Promise<void> {
|
|
223
|
+
this._applyHeaders();
|
|
224
|
+
|
|
225
|
+
// Set request body if we have data
|
|
226
|
+
const body = Buffer.concat(this._chunks);
|
|
227
|
+
if (body.length > 0) {
|
|
228
|
+
const contentType = (this._headers.get('content-type') as string) || 'application/octet-stream';
|
|
229
|
+
this._message.set_request_body_from_bytes(contentType, new GLib.Bytes(body));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
// Send request asynchronously
|
|
234
|
+
const inputStream = await new Promise<Gio.InputStream>((resolve, reject) => {
|
|
235
|
+
this._session.send_async(this._message, GLib.PRIORITY_DEFAULT, this._cancellable, (_self: any, asyncRes: Gio.AsyncResult) => {
|
|
236
|
+
try {
|
|
237
|
+
const stream = this._session.send_finish(asyncRes);
|
|
238
|
+
resolve(stream);
|
|
239
|
+
} catch (error) {
|
|
240
|
+
reject(error);
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// Read the entire response body before emitting 'response'.
|
|
246
|
+
const bodyChunks: Buffer[] = [];
|
|
247
|
+
try {
|
|
248
|
+
let chunk: Uint8Array | null;
|
|
249
|
+
while ((chunk = await readBytesAsync(inputStream, 4096, GLib.PRIORITY_DEFAULT, this._cancellable)) !== null) {
|
|
250
|
+
bodyChunks.push(Buffer.from(chunk));
|
|
251
|
+
}
|
|
252
|
+
} catch (readErr) {
|
|
253
|
+
// Reading may fail if the connection was reset — still emit response with what we have
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Build IncomingMessage from the response
|
|
257
|
+
const res = new IncomingMessage();
|
|
258
|
+
res.statusCode = this._message.status_code;
|
|
259
|
+
res.statusMessage = this._message.get_reason_phrase();
|
|
260
|
+
res.httpVersion = '1.1';
|
|
261
|
+
|
|
262
|
+
// Parse response headers
|
|
263
|
+
const responseHeaders = this._message.get_response_headers();
|
|
264
|
+
responseHeaders.foreach((name: string, value: string) => {
|
|
265
|
+
const lower = name.toLowerCase();
|
|
266
|
+
res.rawHeaders.push(name, value);
|
|
267
|
+
if (lower in res.headers) {
|
|
268
|
+
const existing = res.headers[lower];
|
|
269
|
+
if (Array.isArray(existing)) {
|
|
270
|
+
existing.push(value);
|
|
271
|
+
} else {
|
|
272
|
+
res.headers[lower] = [existing as string, value];
|
|
273
|
+
}
|
|
274
|
+
} else {
|
|
275
|
+
res.headers[lower] = value;
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
this.finished = true;
|
|
280
|
+
|
|
281
|
+
// Clear timeout — response received
|
|
282
|
+
if (this._timeoutTimer) {
|
|
283
|
+
clearTimeout(this._timeoutTimer);
|
|
284
|
+
this._timeoutTimer = null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Emit 'response' so the user can attach 'data'/'end' listeners
|
|
288
|
+
this.emit('response', res);
|
|
289
|
+
|
|
290
|
+
// Now push the buffered body data into the Readable stream.
|
|
291
|
+
// Defer to next tick so listeners from the 'response' handler are attached.
|
|
292
|
+
setTimeout(() => {
|
|
293
|
+
for (const buf of bodyChunks) {
|
|
294
|
+
res.push(buf);
|
|
295
|
+
}
|
|
296
|
+
res.push(null);
|
|
297
|
+
res.complete = true;
|
|
298
|
+
}, 0);
|
|
299
|
+
} catch (error: any) {
|
|
300
|
+
if (this.aborted) {
|
|
301
|
+
this.emit('abort');
|
|
302
|
+
} else {
|
|
303
|
+
this.emit('error', error instanceof Error ? error : new Error(String(error)));
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|