@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/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 -34751
- 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/src/index.ts
CHANGED
|
@@ -1,31 +1,175 @@
|
|
|
1
|
+
// Node.js http module for GJS
|
|
2
|
+
// Server: Soup.Server, Client: Soup.Session
|
|
3
|
+
// Reference: Node.js lib/http.js
|
|
4
|
+
|
|
5
|
+
export { STATUS_CODES, METHODS } from './constants.js';
|
|
6
|
+
export { IncomingMessage } from './incoming-message.js';
|
|
7
|
+
export { OutgoingMessage, Server, ServerResponse } from './server.js';
|
|
8
|
+
export { ClientRequest } from './client-request.js';
|
|
9
|
+
import { IncomingMessage } from './incoming-message.js';
|
|
10
|
+
import { OutgoingMessage, Server, ServerResponse } from './server.js';
|
|
11
|
+
import { ClientRequest } from './client-request.js';
|
|
12
|
+
import type { ClientRequestOptions } from './client-request.js';
|
|
13
|
+
import { URL } from 'node:url';
|
|
14
|
+
|
|
1
15
|
/**
|
|
2
16
|
* Performs the low-level validations on the provided `name` that are done when `res.setHeader(name, value)` is called.
|
|
3
|
-
* Passing illegal value as `name` will result in a `TypeError` being thrown, identified by `code: 'ERR_INVALID_HTTP_TOKEN'`.
|
|
4
|
-
* It is not necessary to use this method before passing headers to an HTTP request or response. The HTTP module will automatically validate such headers.
|
|
5
|
-
* @param name
|
|
6
17
|
* @since v14.3.0
|
|
7
18
|
*/
|
|
8
19
|
export function validateHeaderName(name: string) {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
20
|
+
if (typeof name !== 'string' || !/^[\^`\-\w!#$%&'*+.|~]+$/.test(name)) {
|
|
21
|
+
const error = new TypeError(`Header name must be a valid HTTP token ["${name}"]`);
|
|
22
|
+
Object.defineProperty(error, 'code', { value: 'ERR_INVALID_HTTP_TOKEN' });
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
14
25
|
}
|
|
15
26
|
|
|
16
27
|
/**
|
|
17
28
|
* Performs the low-level validations on the provided `value` that are done when `res.setHeader(name, value)` is called.
|
|
18
|
-
* Passing illegal value as `value` will result in a `TypeError` being thrown.
|
|
19
|
-
* * Undefined value error is identified by `code: 'ERR_HTTP_INVALID_HEADER_VALUE'`.
|
|
20
|
-
* * Invalid value character error is identified by `code: 'ERR_INVALID_CHAR'`.
|
|
21
|
-
* It is not necessary to use this method before passing headers to an HTTP request or response. The HTTP module will automatically validate such headers.
|
|
22
|
-
* @param name
|
|
23
|
-
* @param value
|
|
24
29
|
*/
|
|
25
30
|
export function validateHeaderValue(name: string, value: any) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
if (value === undefined) {
|
|
32
|
+
const error = new TypeError(`Header "${name}" value must not be undefined`);
|
|
33
|
+
Object.defineProperty(error, 'code', { value: 'ERR_HTTP_INVALID_HEADER_VALUE' });
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
if (typeof value === 'string' && /[^\t\u0020-\u007E\u0080-\u00FF]/.test(value)) {
|
|
37
|
+
const error = new TypeError(`Invalid character in header content ["${name}"]`);
|
|
38
|
+
Object.defineProperty(error, 'code', { value: 'ERR_INVALID_CHAR' });
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface AgentOptions {
|
|
44
|
+
keepAlive?: boolean;
|
|
45
|
+
keepAliveMsecs?: number;
|
|
46
|
+
maxSockets?: number;
|
|
47
|
+
maxTotalSockets?: number;
|
|
48
|
+
maxFreeSockets?: number;
|
|
49
|
+
timeout?: number;
|
|
50
|
+
scheduling?: 'fifo' | 'lifo';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Agent class for connection pooling.
|
|
55
|
+
* Soup.Session handles actual TCP connection pooling internally.
|
|
56
|
+
* This class provides the Node.js-compatible API surface for frameworks.
|
|
57
|
+
*/
|
|
58
|
+
export class Agent {
|
|
59
|
+
defaultPort = 80;
|
|
60
|
+
protocol = 'http:';
|
|
61
|
+
maxSockets: number;
|
|
62
|
+
maxTotalSockets: number;
|
|
63
|
+
maxFreeSockets: number;
|
|
64
|
+
keepAliveMsecs: number;
|
|
65
|
+
keepAlive: boolean;
|
|
66
|
+
scheduling: 'fifo' | 'lifo';
|
|
67
|
+
|
|
68
|
+
/** Pending requests per host (compatibility — Soup manages internally). */
|
|
69
|
+
readonly requests: Record<string, unknown[]> = {};
|
|
70
|
+
/** Active sockets per host (compatibility — Soup manages internally). */
|
|
71
|
+
readonly sockets: Record<string, unknown[]> = {};
|
|
72
|
+
/** Idle sockets per host (compatibility — Soup manages internally). */
|
|
73
|
+
readonly freeSockets: Record<string, unknown[]> = {};
|
|
74
|
+
|
|
75
|
+
constructor(options?: AgentOptions) {
|
|
76
|
+
this.keepAlive = options?.keepAlive ?? false;
|
|
77
|
+
this.keepAliveMsecs = options?.keepAliveMsecs ?? 1000;
|
|
78
|
+
this.maxSockets = options?.maxSockets ?? Infinity;
|
|
79
|
+
this.maxTotalSockets = options?.maxTotalSockets ?? Infinity;
|
|
80
|
+
this.maxFreeSockets = options?.maxFreeSockets ?? 256;
|
|
81
|
+
this.scheduling = options?.scheduling ?? 'lifo';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Destroy the agent and close idle connections. */
|
|
85
|
+
destroy(): void {
|
|
86
|
+
// Soup.Session handles cleanup on GC.
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Return a connection pool key for the given options. */
|
|
90
|
+
getName(options: { host?: string; port?: number; localAddress?: string; family?: number }): string {
|
|
91
|
+
let name = options.host || 'localhost';
|
|
92
|
+
if (options.port) name += ':' + options.port;
|
|
93
|
+
if (options.localAddress) name += ':' + options.localAddress;
|
|
94
|
+
if (options.family === 4 || options.family === 6) name += ':' + options.family;
|
|
95
|
+
return name;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const globalAgent = new Agent();
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Create an HTTP server.
|
|
103
|
+
*/
|
|
104
|
+
export function createServer(options?: Record<string, unknown> | ((req: IncomingMessage, res: ServerResponse) => void), requestListener?: (req: IncomingMessage, res: ServerResponse) => void): Server {
|
|
105
|
+
if (typeof options === 'function') {
|
|
106
|
+
return new Server(options);
|
|
107
|
+
}
|
|
108
|
+
return new Server(requestListener);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Make an HTTP request.
|
|
113
|
+
*
|
|
114
|
+
* @param url URL string, URL object, or request options
|
|
115
|
+
* @param options Request options (if url is string/URL)
|
|
116
|
+
* @param callback Response callback
|
|
117
|
+
*/
|
|
118
|
+
export function request(url: string | URL | ClientRequestOptions, options?: ClientRequestOptions | ((res: IncomingMessage) => void), callback?: (res: IncomingMessage) => void): ClientRequest {
|
|
119
|
+
return new ClientRequest(url, options, callback);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Make an HTTP GET request (convenience wrapper that calls req.end() automatically).
|
|
124
|
+
*/
|
|
125
|
+
export function get(url: string | URL | ClientRequestOptions, options?: ClientRequestOptions | ((res: IncomingMessage) => void), callback?: (res: IncomingMessage) => void): ClientRequest {
|
|
126
|
+
// Normalize arguments
|
|
127
|
+
let opts: ClientRequestOptions;
|
|
128
|
+
let cb: ((res: IncomingMessage) => void) | undefined = callback;
|
|
129
|
+
|
|
130
|
+
if (typeof url === 'string' || url instanceof URL) {
|
|
131
|
+
opts = typeof options === 'object' ? { ...options, method: 'GET' } : { method: 'GET' };
|
|
132
|
+
if (typeof options === 'function') cb = options;
|
|
133
|
+
} else {
|
|
134
|
+
opts = { ...url, method: 'GET' };
|
|
135
|
+
if (typeof options === 'function') cb = options;
|
|
136
|
+
url = opts as ClientRequestOptions;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const req = typeof url === 'string' || url instanceof URL
|
|
140
|
+
? new ClientRequest(url, { ...opts, method: 'GET' }, cb)
|
|
141
|
+
: new ClientRequest({ ...opts, method: 'GET' }, cb);
|
|
142
|
+
req.end();
|
|
143
|
+
return req;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Max header size in bytes. */
|
|
147
|
+
export const maxHeaderSize = 16384;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Set the maximum number of idle HTTP parsers. Soup.Session handles
|
|
151
|
+
* connection pooling internally, so this is a no-op for compatibility.
|
|
152
|
+
* @since v18.8.0
|
|
153
|
+
*/
|
|
154
|
+
export function setMaxIdleHTTPParsers(_max: number): void {}
|
|
155
|
+
|
|
156
|
+
import { STATUS_CODES as _STATUS_CODES, METHODS as _METHODS } from './constants.js';
|
|
157
|
+
|
|
158
|
+
export default {
|
|
159
|
+
STATUS_CODES: _STATUS_CODES,
|
|
160
|
+
METHODS: _METHODS,
|
|
161
|
+
Server,
|
|
162
|
+
IncomingMessage,
|
|
163
|
+
OutgoingMessage,
|
|
164
|
+
ServerResponse,
|
|
165
|
+
ClientRequest,
|
|
166
|
+
Agent,
|
|
167
|
+
globalAgent,
|
|
168
|
+
createServer,
|
|
169
|
+
request,
|
|
170
|
+
get,
|
|
171
|
+
validateHeaderName,
|
|
172
|
+
validateHeaderValue,
|
|
173
|
+
maxHeaderSize,
|
|
174
|
+
setMaxIdleHTTPParsers,
|
|
175
|
+
};
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
// Reference: Node.js lib/_http_server.js
|
|
2
|
+
// Reimplemented for GJS using Soup.Server
|
|
3
|
+
|
|
4
|
+
import Soup from '@girs/soup-3.0';
|
|
5
|
+
import Gio from '@girs/gio-2.0';
|
|
6
|
+
import { EventEmitter } from 'node:events';
|
|
7
|
+
import { Writable } from 'node:stream';
|
|
8
|
+
import { Buffer } from 'node:buffer';
|
|
9
|
+
import { Socket as NetSocket } from '@gjsify/net/socket';
|
|
10
|
+
import { deferEmit, ensureMainLoop } from '@gjsify/utils';
|
|
11
|
+
import { STATUS_CODES } from './constants.js';
|
|
12
|
+
import { IncomingMessage } from './incoming-message.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* OutgoingMessage — Base class for ServerResponse and ClientRequest.
|
|
16
|
+
* Reference: Node.js lib/_http_outgoing.js
|
|
17
|
+
*/
|
|
18
|
+
export class OutgoingMessage extends Writable {
|
|
19
|
+
headersSent = false;
|
|
20
|
+
sendDate = true;
|
|
21
|
+
finished = false;
|
|
22
|
+
socket: import('net').Socket | null = null;
|
|
23
|
+
|
|
24
|
+
protected _headers: Map<string, string | string[]> = new Map();
|
|
25
|
+
|
|
26
|
+
/** Set a header. */
|
|
27
|
+
setHeader(name: string, value: string | number | string[]): this {
|
|
28
|
+
this._headers.set(name.toLowerCase(), typeof value === 'number' ? String(value) : value);
|
|
29
|
+
return this;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Get a header. */
|
|
33
|
+
getHeader(name: string): string | string[] | undefined {
|
|
34
|
+
return this._headers.get(name.toLowerCase());
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Remove a header. */
|
|
38
|
+
removeHeader(name: string): void {
|
|
39
|
+
this._headers.delete(name.toLowerCase());
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Check if a header has been set. */
|
|
43
|
+
hasHeader(name: string): boolean {
|
|
44
|
+
return this._headers.has(name.toLowerCase());
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Get all header names. */
|
|
48
|
+
getHeaderNames(): string[] {
|
|
49
|
+
return Array.from(this._headers.keys());
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Get all headers as an object. */
|
|
53
|
+
getHeaders(): Record<string, string | string[]> {
|
|
54
|
+
const result: Record<string, string | string[]> = {};
|
|
55
|
+
for (const [key, value] of this._headers) {
|
|
56
|
+
result[key] = value;
|
|
57
|
+
}
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Append a header value instead of replacing. */
|
|
62
|
+
appendHeader(name: string, value: string | string[]): this {
|
|
63
|
+
const lower = name.toLowerCase();
|
|
64
|
+
const existing = this._headers.get(lower);
|
|
65
|
+
if (existing === undefined) {
|
|
66
|
+
this._headers.set(lower, value);
|
|
67
|
+
} else if (Array.isArray(existing)) {
|
|
68
|
+
if (Array.isArray(value)) {
|
|
69
|
+
existing.push(...value);
|
|
70
|
+
} else {
|
|
71
|
+
existing.push(value);
|
|
72
|
+
}
|
|
73
|
+
} else {
|
|
74
|
+
if (Array.isArray(value)) {
|
|
75
|
+
this._headers.set(lower, [existing as string, ...value]);
|
|
76
|
+
} else {
|
|
77
|
+
this._headers.set(lower, [existing as string, value]);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return this;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Flush headers (no-op in base class). */
|
|
84
|
+
flushHeaders(): void {
|
|
85
|
+
this.headersSent = true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
_write(_chunk: any, _encoding: string, callback: (error?: Error | null) => void): void {
|
|
89
|
+
callback();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* ServerResponse — Writable stream representing an HTTP response.
|
|
95
|
+
* Extends OutgoingMessage for shared header management.
|
|
96
|
+
*/
|
|
97
|
+
export class ServerResponse extends OutgoingMessage {
|
|
98
|
+
statusCode = 200;
|
|
99
|
+
statusMessage = '';
|
|
100
|
+
|
|
101
|
+
private _streaming = false;
|
|
102
|
+
private _soupMsg: Soup.ServerMessage;
|
|
103
|
+
private _timeoutTimer: ReturnType<typeof setTimeout> | null = null;
|
|
104
|
+
|
|
105
|
+
constructor(soupMsg: Soup.ServerMessage) {
|
|
106
|
+
super();
|
|
107
|
+
this._soupMsg = soupMsg;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Set a timeout for the response. Emits 'timeout' if response not sent within msecs. */
|
|
111
|
+
setTimeout(msecs: number, callback?: () => void): this {
|
|
112
|
+
if (this._timeoutTimer) {
|
|
113
|
+
clearTimeout(this._timeoutTimer);
|
|
114
|
+
this._timeoutTimer = null;
|
|
115
|
+
}
|
|
116
|
+
if (callback) this.once('timeout', callback);
|
|
117
|
+
if (msecs > 0) {
|
|
118
|
+
this._timeoutTimer = setTimeout(() => {
|
|
119
|
+
this._timeoutTimer = null;
|
|
120
|
+
this.emit('timeout');
|
|
121
|
+
}, msecs);
|
|
122
|
+
}
|
|
123
|
+
return this;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Write the status line and headers. */
|
|
127
|
+
writeHead(statusCode: number, statusMessage?: string | Record<string, string | string[]>, headers?: Record<string, string | string[]>): this {
|
|
128
|
+
this.statusCode = statusCode;
|
|
129
|
+
|
|
130
|
+
if (typeof statusMessage === 'object') {
|
|
131
|
+
headers = statusMessage;
|
|
132
|
+
statusMessage = undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
this.statusMessage = (statusMessage as string) || STATUS_CODES[statusCode] || '';
|
|
136
|
+
|
|
137
|
+
if (headers) {
|
|
138
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
139
|
+
this.setHeader(key, value);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return this;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Send a 100 Continue response. */
|
|
147
|
+
writeContinue(callback?: () => void): void {
|
|
148
|
+
// Soup.Server handles 100-Continue automatically, but we track the call
|
|
149
|
+
if (callback) Promise.resolve().then(callback);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Send a 102 Processing response (WebDAV). */
|
|
153
|
+
writeProcessing(callback?: () => void): void {
|
|
154
|
+
if (callback) Promise.resolve().then(callback);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Flush headers (send them immediately). */
|
|
158
|
+
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
|
+
if (!this.headersSent) {
|
|
162
|
+
this.headersSent = true;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Add trailing headers for chunked transfer encoding. */
|
|
167
|
+
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.
|
|
170
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
171
|
+
// Trailers are appended after the body in chunked encoding
|
|
172
|
+
this._headers.set('trailer-' + key.toLowerCase(), value);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
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.
|
|
179
|
+
*/
|
|
180
|
+
private _startStreaming(): void {
|
|
181
|
+
if (this._streaming) return;
|
|
182
|
+
this._streaming = true;
|
|
183
|
+
this.headersSent = true;
|
|
184
|
+
|
|
185
|
+
if (this._timeoutTimer) {
|
|
186
|
+
clearTimeout(this._timeoutTimer);
|
|
187
|
+
this._timeoutTimer = null;
|
|
188
|
+
}
|
|
189
|
+
|
|
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
|
+
for (const [key, value] of this._headers) {
|
|
200
|
+
if (Array.isArray(value)) {
|
|
201
|
+
for (const v of value) {
|
|
202
|
+
responseHeaders.append(key, v);
|
|
203
|
+
}
|
|
204
|
+
} else {
|
|
205
|
+
responseHeaders.replace(key, value as string);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Writable stream _write — sends headers on first call, then appends + flushes each chunk. */
|
|
211
|
+
_write(chunk: any, encoding: string, callback: (error?: Error | null) => void): void {
|
|
212
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding as BufferEncoding);
|
|
213
|
+
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();
|
|
218
|
+
callback();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Called by Writable.end() — completes the body (streaming) or sends batch response (no-body). */
|
|
222
|
+
_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);
|
|
258
|
+
}
|
|
259
|
+
} else {
|
|
260
|
+
responseHeaders.replace(key, value as string);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
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));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Write status + headers + body in one call (convenience). */
|
|
270
|
+
end(chunk?: unknown, encoding?: BufferEncoding | (() => void), callback?: () => void): this {
|
|
271
|
+
if (typeof chunk === 'function') {
|
|
272
|
+
callback = chunk as () => void;
|
|
273
|
+
chunk = undefined;
|
|
274
|
+
} else if (typeof encoding === 'function') {
|
|
275
|
+
callback = encoding;
|
|
276
|
+
encoding = undefined;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (chunk != null) {
|
|
280
|
+
this.write(chunk as string | Buffer, encoding as BufferEncoding);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
super.end(callback);
|
|
284
|
+
return this;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
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.
|
|
292
|
+
const _activeServers = new Set<Server>();
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* HTTP Server wrapping Soup.Server.
|
|
296
|
+
*/
|
|
297
|
+
export class Server extends EventEmitter {
|
|
298
|
+
listening = false;
|
|
299
|
+
maxHeadersCount = 2000;
|
|
300
|
+
timeout = 0;
|
|
301
|
+
keepAliveTimeout = 5000;
|
|
302
|
+
headersTimeout = 60000;
|
|
303
|
+
requestTimeout = 300000;
|
|
304
|
+
|
|
305
|
+
private _soupServer: Soup.Server | null = null;
|
|
306
|
+
private _address: { port: number; family: string; address: string } | null = null;
|
|
307
|
+
|
|
308
|
+
constructor(requestListener?: ((req: IncomingMessage, res: ServerResponse) => void) | Record<string, unknown>);
|
|
309
|
+
constructor(options: Record<string, unknown>, requestListener?: (req: IncomingMessage, res: ServerResponse) => void);
|
|
310
|
+
constructor(
|
|
311
|
+
optionsOrListener?: ((req: IncomingMessage, res: ServerResponse) => void) | Record<string, unknown>,
|
|
312
|
+
requestListener?: (req: IncomingMessage, res: ServerResponse) => void,
|
|
313
|
+
) {
|
|
314
|
+
super();
|
|
315
|
+
// Support Node.js signature: new Server(options, listener)
|
|
316
|
+
const listener = typeof optionsOrListener === 'function' ? optionsOrListener : requestListener;
|
|
317
|
+
if (listener) {
|
|
318
|
+
this.on('request', listener);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
listen(port?: number, hostname?: string, backlog?: number, callback?: () => void): this;
|
|
323
|
+
listen(port?: number, hostname?: string, callback?: () => void): this;
|
|
324
|
+
listen(port?: number, callback?: () => void): this;
|
|
325
|
+
listen(...args: unknown[]): this {
|
|
326
|
+
let port = 0;
|
|
327
|
+
let hostname = '0.0.0.0';
|
|
328
|
+
let callback: (() => void) | undefined;
|
|
329
|
+
|
|
330
|
+
for (const arg of args) {
|
|
331
|
+
if (typeof arg === 'number') port = arg;
|
|
332
|
+
else if (typeof arg === 'string') hostname = arg;
|
|
333
|
+
else if (typeof arg === 'function') callback = arg as () => void;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (callback) this.once('listening', callback);
|
|
337
|
+
|
|
338
|
+
try {
|
|
339
|
+
this._soupServer = new Soup.Server({});
|
|
340
|
+
|
|
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);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
this._soupServer.listen_local(port, Soup.ServerListenOptions.IPV4_ONLY);
|
|
347
|
+
ensureMainLoop();
|
|
348
|
+
|
|
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
|
+
}
|
|
358
|
+
|
|
359
|
+
this.listening = true;
|
|
360
|
+
this._address = { port: actualPort, family: 'IPv4', address: hostname };
|
|
361
|
+
_activeServers.add(this);
|
|
362
|
+
|
|
363
|
+
deferEmit(this, 'listening');
|
|
364
|
+
} 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);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return this;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
private _handleRequest(soupMsg: Soup.ServerMessage, path: string): void {
|
|
377
|
+
const req = new IncomingMessage();
|
|
378
|
+
const res = new ServerResponse(soupMsg);
|
|
379
|
+
|
|
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;
|
|
385
|
+
req.httpVersion = '1.1';
|
|
386
|
+
|
|
387
|
+
// Parse headers
|
|
388
|
+
const requestHeaders = soupMsg.get_request_headers();
|
|
389
|
+
requestHeaders.foreach((name: string, value: string) => {
|
|
390
|
+
const lower = name.toLowerCase();
|
|
391
|
+
req.rawHeaders.push(name, value);
|
|
392
|
+
if (lower in req.headers) {
|
|
393
|
+
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
|
+
}
|
|
399
|
+
} else {
|
|
400
|
+
req.headers[lower] = value;
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
|
|
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.
|
|
417
|
+
}
|
|
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;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
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();
|
|
437
|
+
|
|
438
|
+
res.on('finish', () => {
|
|
439
|
+
soupMsg.unpause();
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
this.emit('request', req, res);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
address(): { port: number; family: string; address: string } | null {
|
|
446
|
+
return this._address;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Register a WebSocket handler on this server (GJS only).
|
|
451
|
+
* Delegates to Soup.Server.add_websocket_handler().
|
|
452
|
+
* @param path URL path to handle WebSocket upgrades (e.g., '/ws')
|
|
453
|
+
* @param callback Called for each new WebSocket connection with the Soup.WebsocketConnection
|
|
454
|
+
*/
|
|
455
|
+
addWebSocketHandler(
|
|
456
|
+
path: string,
|
|
457
|
+
callback: (connection: unknown) => void,
|
|
458
|
+
): void {
|
|
459
|
+
if (!this._soupServer) {
|
|
460
|
+
throw new Error('Server must be listening before adding WebSocket handlers. Call listen() first.');
|
|
461
|
+
}
|
|
462
|
+
this._soupServer.add_websocket_handler(
|
|
463
|
+
path, null, null,
|
|
464
|
+
(_srv: Soup.Server, _msg: Soup.ServerMessage, _path: string, connection: unknown) => {
|
|
465
|
+
callback(connection);
|
|
466
|
+
},
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
close(callback?: (err?: Error) => void): this {
|
|
471
|
+
if (callback) this.once('close', callback);
|
|
472
|
+
|
|
473
|
+
if (this._soupServer) {
|
|
474
|
+
this._soupServer.disconnect();
|
|
475
|
+
this._soupServer = null;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
this.listening = false;
|
|
479
|
+
_activeServers.delete(this);
|
|
480
|
+
deferEmit(this, 'close');
|
|
481
|
+
return this;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
setTimeout(msecs: number, callback?: () => void): this {
|
|
485
|
+
this.timeout = msecs;
|
|
486
|
+
if (callback) this.on('timeout', callback);
|
|
487
|
+
return this;
|
|
488
|
+
}
|
|
489
|
+
}
|