Skip to main content

pi/
http_shim.rs

1//! Node.js `http` and `https` shim — pure-JS implementation for the QuickJS
2//! extension runtime.
3//!
4//! Provides `http.request`, `http.get`, `https.request`, `https.get` that route
5//! all HTTP traffic through the capability-gated `pi.http()` hostcall. Uses the
6//! `EventEmitter` from `node:events` for the standard Node.js event-based API.
7
8/// The JS source for the `node:http` virtual module.
9pub const NODE_HTTP_JS: &str = r#"
10import EventEmitter from "node:events";
11
12// ─── STATUS_CODES ────────────────────────────────────────────────────────────
13
14const STATUS_CODES = {
15  200: 'OK', 201: 'Created', 204: 'No Content',
16  301: 'Moved Permanently', 302: 'Found', 304: 'Not Modified',
17  400: 'Bad Request', 401: 'Unauthorized', 403: 'Forbidden',
18  404: 'Not Found', 405: 'Method Not Allowed', 408: 'Request Timeout',
19  500: 'Internal Server Error', 502: 'Bad Gateway', 503: 'Service Unavailable',
20};
21
22const METHODS = [
23  'GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'CONNECT',
24  'OPTIONS', 'TRACE', 'PATCH',
25];
26
27function __pi_http_is_binary_chunk(chunk) {
28  if (typeof Buffer !== 'undefined' && Buffer.isBuffer && Buffer.isBuffer(chunk)) {
29    return true;
30  }
31  if (chunk instanceof Uint8Array || chunk instanceof ArrayBuffer) {
32    return true;
33  }
34  return !!(ArrayBuffer.isView && ArrayBuffer.isView(chunk));
35}
36
37function __pi_http_to_uint8(chunk) {
38  if (typeof Buffer !== 'undefined' && Buffer.isBuffer && Buffer.isBuffer(chunk)) {
39    return new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);
40  }
41  if (chunk instanceof Uint8Array) {
42    return chunk;
43  }
44  if (chunk instanceof ArrayBuffer) {
45    return new Uint8Array(chunk);
46  }
47  if (ArrayBuffer.isView && ArrayBuffer.isView(chunk)) {
48    return new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);
49  }
50  return new TextEncoder().encode(String(chunk ?? ''));
51}
52
53function __pi_http_clone_body_chunk(chunk) {
54  const view = __pi_http_to_uint8(chunk);
55  if (typeof Buffer !== 'undefined' && typeof Buffer.from === 'function') {
56    return Buffer.from(view);
57  }
58  return new Uint8Array(view);
59}
60
61function __pi_http_chunks_to_base64(chunks) {
62  const parts = chunks.map((chunk) => __pi_http_to_uint8(chunk));
63  const total = parts.reduce((sum, part) => sum + part.byteLength, 0);
64  const merged =
65    typeof Buffer !== 'undefined' && typeof Buffer.alloc === 'function'
66      ? Buffer.alloc(total)
67      : new Uint8Array(total);
68
69  let offset = 0;
70  for (const part of parts) {
71    merged.set(part, offset);
72    offset += part.byteLength;
73  }
74
75  if (typeof globalThis.__pi_base64_encode_bytes_native === 'function') {
76    return __pi_base64_encode_bytes_native(merged);
77  }
78
79  // Fallback for older runtime bounds
80  let binary = '';
81  let chunk = [];
82  for (let i = 0; i < merged.length; i++) {
83    chunk.push(merged[i]);
84    if (chunk.length >= 4096) {
85      binary += String.fromCharCode.apply(null, chunk);
86      chunk.length = 0;
87    }
88  }
89  if (chunk.length > 0) {
90    binary += String.fromCharCode.apply(null, chunk);
91  }
92  return __pi_base64_encode_native(binary);
93}
94
95function __pi_http_decode_body_bytes(bodyBytes) {
96  const encoded = String(bodyBytes ?? '');
97  const binary = globalThis.__pi_base64_decode_native(encoded);
98  const out =
99    typeof Buffer !== 'undefined' && typeof Buffer.alloc === 'function'
100      ? Buffer.alloc(binary.length)
101      : new Uint8Array(binary.length);
102
103  for (let i = 0; i < binary.length; i++) {
104    out[i] = binary.charCodeAt(i) & 0xff;
105  }
106  return out;
107}
108
109function __pi_http_decode_chunk(chunk, encoding) {
110  if (!encoding || typeof chunk === 'string') {
111    return chunk;
112  }
113
114  const bytes = __pi_http_to_uint8(chunk);
115  if (typeof Buffer !== 'undefined' && typeof Buffer.from === 'function') {
116    return Buffer.from(bytes).toString(encoding);
117  }
118  return new TextDecoder(encoding).decode(bytes);
119}
120
121// ─── IncomingMessage ─────────────────────────────────────────────────────────
122
123class IncomingMessage extends EventEmitter {
124  constructor(statusCode, headers, body) {
125    super();
126    this.statusCode = statusCode;
127    this.statusMessage = STATUS_CODES[statusCode] || 'Unknown';
128    this.headers = headers || {};
129    this._body = body || '';
130    this._destroyed = false;
131    this.complete = false;
132    this.httpVersion = '1.1';
133    this.method = null;
134    this.url = '';
135  }
136
137  _deliver() {
138    if (this._destroyed) {
139      return;
140    }
141
142    const chunk = __pi_http_decode_chunk(this._body, this._encoding);
143    if (chunk && chunk.length > 0) {
144      this.emit('data', chunk);
145    }
146
147    if (this._destroyed) {
148      return;
149    }
150
151    this.complete = true;
152    this.emit('end');
153  }
154
155  setEncoding(encoding) {
156    this._encoding = encoding ? String(encoding) : 'utf8';
157    return this;
158  }
159  resume() { return this; }
160  pause() { return this; }
161  destroy() {
162    if (this._destroyed) {
163      return this;
164    }
165    this._destroyed = true;
166    this.emit('close');
167    return this;
168  }
169}
170
171// ─── ClientRequest ───────────────────────────────────────────────────────────
172
173class ClientRequest extends EventEmitter {
174  constructor(options, callback) {
175    super();
176    this._options = options;
177    this._body = [];
178    this._ended = false;
179    this._aborted = false;
180    this._headers = {};
181    this.socket = { remoteAddress: '127.0.0.1', remotePort: 0 };
182    this.method = options.method || 'GET';
183    this.path = options.path || '/';
184
185    if (options.headers) {
186      for (const [k, v] of Object.entries(options.headers)) {
187        this._headers[String(k).toLowerCase()] = String(v);
188      }
189    }
190
191    if (typeof callback === 'function') {
192      this.once('response', callback);
193    }
194  }
195
196  write(chunk) {
197    if (!this._ended && !this._aborted) {
198      this._body.push(
199        __pi_http_is_binary_chunk(chunk)
200          ? __pi_http_clone_body_chunk(chunk)
201          : String(chunk)
202      );
203    }
204    return true;
205  }
206
207  end(chunk, _encoding, callback) {
208    if (typeof chunk === 'function') { callback = chunk; chunk = undefined; }
209    if (typeof _encoding === 'function') { callback = _encoding; }
210    if (chunk) this.write(chunk);
211    if (typeof callback === 'function') this.once('finish', callback);
212
213    this._ended = true;
214    this._send();
215    return this;
216  }
217
218  abort() {
219    this._aborted = true;
220    this.emit('abort');
221    this.destroy();
222  }
223
224  destroy(error) {
225    this._aborted = true;
226    if (error) this.emit('error', error);
227    this.emit('close');
228    return this;
229  }
230
231  setTimeout(ms, callback) {
232    if (typeof callback === 'function') this.once('timeout', callback);
233    this._timeoutMs = ms;
234    return this;
235  }
236
237  setNoDelay() { return this; }
238  setSocketKeepAlive() { return this; }
239  flushHeaders() {}
240  getHeader(name) { return this._headers[String(name).toLowerCase()]; }
241  setHeader(name, value) {
242    if (!this._ended && !this._aborted) {
243      this._headers[String(name).toLowerCase()] = String(value);
244    }
245    return this;
246  }
247  removeHeader(name) {
248    if (!this._ended && !this._aborted) {
249      delete this._headers[String(name).toLowerCase()];
250    }
251    return this;
252  }
253
254  _send() {
255    if (this._aborted) {
256      return;
257    }
258
259    const opts = this._options;
260    const protocol = opts.protocol || 'http:';
261    const hostname = opts.hostname || opts.host || 'localhost';
262    const port = opts.port ? `:${opts.port}` : '';
263    const path = opts.path || '/';
264    const url = `${protocol}//${hostname}${port}${path}`;
265
266    const headers = { ...this._headers };
267
268    const method = (opts.method || 'GET').toUpperCase();
269    const request = { url, method, headers };
270    if (this._body.length > 0) {
271      const hasBinaryChunk = this._body.some((chunk) => __pi_http_is_binary_chunk(chunk));
272      if (hasBinaryChunk) {
273        request.body_bytes = __pi_http_chunks_to_base64(this._body);
274      } else {
275        request.body = this._body.join('');
276      }
277    }
278    if (this._timeoutMs) request.timeout = this._timeoutMs;
279
280    // Use pi.http() hostcall if available
281    if (typeof globalThis.pi === 'object' && typeof globalThis.pi.http === 'function') {
282      try {
283        const promise = globalThis.pi.http(request);
284        if (promise && typeof promise.then === 'function') {
285          promise.then(
286            (result) => {
287              if (!this._aborted) {
288                this._handleResponse(result);
289              }
290            },
291            (err) => {
292              if (!this._aborted) {
293                this.emit('error', typeof err === 'string' ? new Error(err) : err);
294              }
295            }
296          );
297        } else {
298          this._handleResponse(promise);
299        }
300      } catch (err) {
301        this.emit('error', err);
302      }
303    } else {
304      // No pi.http available — emit error
305      this.emit('error', new Error('HTTP requests require pi.http() hostcall'));
306    }
307
308    this.emit('finish');
309  }
310
311  _handleResponse(result) {
312    if (this._aborted) {
313      return;
314    }
315
316    if (!result || typeof result !== 'object') {
317      this.emit('error', new Error('Invalid HTTP response from hostcall'));
318      return;
319    }
320
321    const statusCode = result.status || result.statusCode || 200;
322    const headers = result.headers || {};
323    const body =
324      result.body_bytes !== undefined && result.body_bytes !== null
325        ? __pi_http_decode_body_bytes(result.body_bytes)
326        : (result.body || result.data || '');
327
328    const res = new IncomingMessage(statusCode, headers, body);
329    this.emit('response', res);
330    // Deliver body asynchronously (in next microtask)
331    Promise.resolve().then(() => {
332      if (!this._aborted) {
333        res._deliver();
334      }
335    });
336  }
337}
338
339// ─── Module API ──────────────────────────────────────────────────────────────
340
341function _parseOptions(input, options) {
342  if (typeof input === 'string') {
343    try {
344      const url = new URL(input);
345      return {
346        protocol: url.protocol,
347        hostname: url.hostname,
348        port: url.port || undefined,
349        path: url.pathname + url.search,
350        ...(options || {}),
351      };
352    } catch (_e) {
353      return { path: input, ...(options || {}) };
354    }
355  }
356  if (input && typeof input === 'object' && !(input instanceof URL)) {
357    return { ...input };
358  }
359  if (input instanceof URL) {
360    return {
361      protocol: input.protocol,
362      hostname: input.hostname,
363      port: input.port || undefined,
364      path: input.pathname + input.search,
365      ...(options || {}),
366    };
367  }
368  return options || {};
369}
370
371export function request(input, optionsOrCallback, callback) {
372  let options;
373  if (typeof optionsOrCallback === 'function') {
374    callback = optionsOrCallback;
375    options = _parseOptions(input);
376  } else {
377    options = _parseOptions(input, optionsOrCallback);
378  }
379  if (!options.protocol) options.protocol = 'http:';
380  return new ClientRequest(options, callback);
381}
382
383export function get(input, optionsOrCallback, callback) {
384  const req = request(input, optionsOrCallback, callback);
385  req.end();
386  return req;
387}
388
389export function createServer() {
390  throw new Error('node:http.createServer is not available in PiJS');
391}
392
393export { STATUS_CODES, METHODS, IncomingMessage, ClientRequest };
394export default { request, get, createServer, STATUS_CODES, METHODS, IncomingMessage, ClientRequest };
395"#;
396
397/// The JS source for the `node:https` virtual module.
398pub const NODE_HTTPS_JS: &str = r#"
399import EventEmitter from "node:events";
400import * as http from "node:http";
401
402export function request(input, optionsOrCallback, callback) {
403  let options;
404  if (typeof optionsOrCallback === 'function') {
405    callback = optionsOrCallback;
406    options = typeof input === 'string' || input instanceof URL
407      ? { ...(typeof input === 'string' ? (() => { try { const u = new URL(input); return { protocol: u.protocol, hostname: u.hostname, port: u.port, path: u.pathname + u.search }; } catch(_) { return { path: input }; } })() : { protocol: input.protocol, hostname: input.hostname, port: input.port, path: input.pathname + input.search }) }
408      : { ...(input || {}) };
409  } else {
410    options = typeof input === 'string' || input instanceof URL
411      ? { ...(typeof input === 'string' ? (() => { try { const u = new URL(input); return { protocol: u.protocol, hostname: u.hostname, port: u.port, path: u.pathname + u.search }; } catch(_) { return { path: input }; } })() : { protocol: input.protocol, hostname: input.hostname, port: input.port, path: input.pathname + input.search }), ...(optionsOrCallback || {}) }
412      : { ...(input || {}), ...(optionsOrCallback || {}) };
413  }
414  if (!options.protocol) options.protocol = 'https:';
415  return http.request(options, callback);
416}
417
418export function get(input, optionsOrCallback, callback) {
419  const req = request(input, optionsOrCallback, callback);
420  req.end();
421  return req;
422}
423
424export function createServer() {
425  throw new Error('node:https.createServer is not available in PiJS');
426}
427
428export const globalAgent = {};
429
430export default { request, get, createServer, globalAgent };
431"#;