@gjsify/http 0.0.4 → 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/package.json CHANGED
@@ -1,32 +1,26 @@
1
1
  {
2
2
  "name": "@gjsify/http",
3
- "version": "0.0.4",
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
- "import": {
11
- "types": "./lib/types/index.d.ts",
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
- "print:name": "echo '@gjsify/http'",
23
- "build": "yarn print:name && yarn build:gjsify",
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 --project tsconfig.types.json || exit 0",
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 print:name && yarn build:gjsify && yarn build:test && yarn test:node && yarn test:gjs",
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.4",
40
- "@gjsify/unit": "^0.0.4",
41
- "@types/node": "^20.10.5"
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
+ }