UNPKG

@socketsupply/socket

Version:

A Cross-Platform, Native Runtime for Desktop and Mobile Apps — Create apps using HTML, CSS, and JavaScript. Written from the ground up to be small and maintainable.

1,902 lines (1,682 loc) 42.4 kB
import { Writable, Readable, Duplex } from './stream.js' import { AsyncResource } from './async/resource.js' import { AsyncContext } from './async/context.js' import { EventEmitter } from './events.js' import { toProperCase } from './util.js' import { Buffer } from './buffer.js' import location from './location.js' import adapters from './http/adapters.js' import gc from './gc.js' // re-export import * as exports from './http.js' /** * All known possible HTTP methods. * @type {string[]} */ export const METHODS = [ 'ACL', 'BIND', 'CHECKOUT', 'CONNECT', 'COPY', 'DELETE', 'GET', 'HEAD', 'LINK', 'LOCK', 'M-SEARCH', 'MERGE', 'MKACTIVITY', 'MKCALENDAR', 'MKCOL', 'MOVE', 'NOTIFY', 'OPTIONS', 'PATCH', 'POST', 'PROPFIND', 'PROPPATCH', 'PURGE', 'PUT', 'QUERY', 'REBIND', 'REPORT', 'SEARCH', 'SOURCE', 'SUBSCRIBE', 'TRACE', 'UNBIND', 'UNLINK', 'UNLOCK', 'UNSUBSCRIBE' ] /** * A mapping of status codes to status texts * @type {object} */ export const STATUS_CODES = { 100: 'Continue', 101: 'Switching Protocols', 102: 'Processing', 103: 'Early Hints', 200: 'OK', 201: 'Created', 202: 'Accepted', 203: 'Non-Authoritative Information', 204: 'No Content', 205: 'Reset Content', 206: 'Partial Content', 207: 'Multi-Status', 208: 'Already Reported', 226: 'IM Used', 300: 'Multiple Choices', 301: 'Moved Permanently', 302: 'Found', 303: 'See Other', 304: 'Not Modified', 305: 'Use Proxy', 307: 'Temporary Redirect', 308: 'Permanent Redirect', 400: 'Bad Request', 401: 'Unauthorized', 402: 'Payment Required', 403: 'Forbidden', 404: 'Not Found', 405: 'Method Not Allowed', 406: 'Not Acceptable', 407: 'Proxy Authentication Required', 408: 'Request Timeout', 409: 'Conflict', 410: 'Gone', 411: 'Length Required', 412: 'Precondition Failed', 413: 'Payload Too Large', 414: 'URI Too Long', 415: 'Unsupported Media Type', 416: 'Range Not Satisfiable', 417: 'Expectation Failed', 418: "I'm a Teapot", 421: 'Misdirected Request', 422: 'Unprocessable Entity', 423: 'Locked', 424: 'Failed Dependency', 425: 'Too Early', 426: 'Upgrade Required', 428: 'Precondition Required', 429: 'Too Many Requests', 431: 'Request Header Fields Too Large', 451: 'Unavailable For Legal Reasons', 500: 'Internal Server Error', 501: 'Not Implemented', 502: 'Bad Gateway', 503: 'Service Unavailable', 504: 'Gateway Timeout', 505: 'HTTP Version Not Supported', 506: 'Variant Also Negotiates', 507: 'Insufficient Storage', 508: 'Loop Detected', 509: 'Bandwidth Limit Exceeded', 510: 'Not Extended', 511: 'Network Authentication Required' } export const CONTINUE = 100 export const SWITCHING_PROTOCOLS = 101 export const PROCESSING = 102 export const EARLY_HINTS = 103 export const OK = 200 export const CREATED = 201 export const ACCEPTED = 202 export const NONAUTHORITATIVE_INFORMATION = 203 export const NO_CONTENT = 204 export const RESET_CONTENT = 205 export const PARTIAL_CONTENT = 206 export const MULTISTATUS = 207 export const ALREADY_REPORTED = 208 export const IM_USED = 226 export const MULTIPLE_CHOICES = 300 export const MOVED_PERMANENTLY = 301 export const FOUND = 302 export const SEE_OTHER = 303 export const NOT_MODIFIED = 304 export const USE_PROXY = 305 export const TEMPORARY_REDIRECT = 307 export const PERMANENT_REDIRECT = 308 export const BAD_REQUEST = 400 export const UNAUTHORIZED = 401 export const PAYMENT_REQUIRED = 402 export const FORBIDDEN = 403 export const NOT_FOUND = 404 export const METHOD_NOT_ALLOWED = 405 export const NOT_ACCEPTABLE = 406 export const PROXY_AUTHENTICATION_REQUIRED = 407 export const REQUEST_TIMEOUT = 408 export const CONFLICT = 409 export const GONE = 410 export const LENGTH_REQUIRED = 411 export const PRECONDITION_FAILED = 412 export const PAYLOAD_TOO_LARGE = 413 export const URI_TOO_LONG = 414 export const UNSUPPORTED_MEDIA_TYPE = 415 export const RANGE_NOT_SATISFIABLE = 416 export const EXPECTATION_FAILED = 417 export const IM_A_TEAPOT = 418 export const MISDIRECTED_REQUEST = 421 export const UNPROCESSABLE_ENTITY = 422 export const LOCKED = 423 export const FAILED_DEPENDENCY = 424 export const TOO_EARLY = 425 export const UPGRADE_REQUIRED = 426 export const PRECONDITION_REQUIRED = 428 export const TOO_MANY_REQUESTS = 429 export const REQUEST_HEADER_FIELDS_TOO_LARGE = 431 export const UNAVAILABLE_FOR_LEGAL_REASONS = 451 export const INTERNAL_SERVER_ERROR = 500 export const NOT_IMPLEMENTED = 501 export const BAD_GATEWAY = 502 export const SERVICE_UNAVAILABLE = 503 export const GATEWAY_TIMEOUT = 504 export const HTTP_VERSION_NOT_SUPPORTED = 505 export const VARIANT_ALSO_NEGOTIATES = 506 export const INSUFFICIENT_STORAGE = 507 export const LOOP_DETECTED = 508 export const BANDWIDTH_LIMIT_EXCEEDED = 509 export const NOT_EXTENDED = 510 export const NETWORK_AUTHENTICATION_REQUIRED = 511 /** * The parent class of `ClientRequest` and `ServerResponse`. * It is an abstract outgoing message from the perspective of the * participants of an HTTP transaction. * @see {@link https://nodejs.org/api/http.html#class-httpoutgoingmessage} */ export class OutgoingMessage extends Writable { #headers = new Headers() #timeout = null #finished = false #buffers = [] /** * `true` if the headers were sent * @type {boolean} */ headersSent = false /** * `OutgoingMessage` class constructor. * @ignore */ constructor () { super({ write: (data, callback) => { this.emit('writebuffer') this.buffers.push(Buffer.from(data)) callback(null) } }) this.once('finish', () => { this.#finished = true if (this.#timeout) { clearTimeout(this.#timeout) } }) } /** * Internal buffers * @ignore * @type {Buffer[]} */ get buffers () { return this.#buffers } /** * An object of the outgoing message headers. * This is equivalent to `getHeaders()` * @type {object} */ get headers () { return Object.fromEntries(this.#headers.entries()) } /** * @ignore */ get socket () { return this } /** * `true` if the write state is "ended" * @type {boolean} */ get writableEnded () { return this._writableState?.ended === true } /** * `true` if the write state is "finished" * @type {boolean} */ get writableFinished () { return this.#finished } /** * The number of buffered bytes. * @type {number} */ get writableLength () { return this._writableState.buffered } /** * @ignore * @type {boolean} */ get writableObjectMode () { return false } /** * @ignore */ get writableCorked () { return 0 } /** * The `highWaterMark` of the writable stream. * @type {number} */ get writableHighWaterMark () { return this._writableState.highWaterMark } /** * @ignore * @return {OutgoingMessage} */ addTrailers (headers) { // not supported return this } /** * @ignore * @return {OutgoingMessage} */ cork () { // not supported return this } /** * @ignore * @return {OutgoingMessage} */ uncork () { // not supported return this } /** * Destroys the message. * Once a socket is associated with the message and is connected, * that socket will be destroyed as well. * @param {Error?} [err] * @return {OutgoingMessage} */ destroy (err = null) { super.destroy(err) return this } /** * Finishes the outgoing message. * @param {(Buffer|Uint8Array|string|function)=} [chunk] * @param {(string|function)=} [encoding] * @param {function=} [callback] * @return {OutgoingMessage} */ end (chunk = null, encoding = null, callback = null) { if (typeof chunk === 'function') { callback = chunk chunk = null encoding = null } else if (typeof encoding === 'function') { callback = encoding encoding = null } if (typeof callback === 'function') { this.once('finish', callback) } if (chunk !== null) { this.write(chunk) } this.emit('prefinish') super.end(null) return this } /** * Append a single header value for the header object. * @param {string} name * @param {string|string[]} value * @return {OutgoingMessage} */ appendHeader (name, value) { if (name && typeof name === 'string') { if (Array.isArray(value)) { for (const v of value) { this.#headers.append(name.toLowerCase(), v) } } else { this.#headers.append(name.toLowerCase(), value) } } return this } /** * Append a single header value for the header object. * @param {string} name * @param {string} value * @return {OutgoingMessage} */ setHeader (name, value) { if (name && typeof name === 'string') { this.#headers.set(name.toLowerCase(), value) } return this } /** * Flushes the message headers. */ flushHeaders () { queueMicrotask(() => this.emit('flushheaders')) } /** * Gets the value of the HTTP header with the given name. * If that header is not set, the returned value will be `undefined`. * @param {string} * @return {string|undefined} */ getHeader (name) { if (name && typeof name === 'string') { return this.#headers.get(name.toLowerCase()) ?? undefined } return undefined } /** * Returns an array containing the unique names of the current outgoing * headers. All names are lowercase. * @return {string[]} */ getHeaderNames () { return Array.from(this.#headers.keys()) } /** * @ignore */ getRawHeaderNames () { return this.getHeaderNames() .map((name) => name.split('-').map(toProperCase).join('-')) } /** * Returns a copy of the HTTP headers as an object. * @return {object} */ getHeaders () { return Object.fromEntries(this.#headers.entries()) } /** * Returns true if the header identified by name is currently set in the * outgoing headers. The header name is case-insensitive. * @param {string} name * @return {boolean} */ hasHeader (name) { if (name && typeof name === 'string') { return this.#headers.has(name.toLowerCase()) } return false } /** * Removes a header that is queued for implicit sending. * @param {string} name */ removeHeader (name) { if (name && typeof name === 'string') { this.#headers.delete(name.toLowerCase()) } } /** * Sets the outgoing message timeout with an optional callback. * @param {number} timeout * @param {function=} [callback] * @return {OutgoingMessage} */ setTimeout (timeout, callback = null) { if (!timeout || !Number.isFinite(timeout)) { throw new TypeError('Expecting a finite integer for a timeout') } if (typeof callback === 'function') { this.once('timeout', callback) } this.#timeout = setTimeout(() => { this.emit('timeout') }, timeout) return this } /** * @ignore */ _implicitHeader () { throw new TypeError('_implicitHeader is not implemented') } } /** * An `IncomingMessage` object is created by `Server` or `ClientRequest` and * passed as the first argument to the 'request' and 'response' event * respectively. * It may be used to access response status, headers, and data. * @see {@link https://nodejs.org/api/http.html#class-httpincomingmessage} */ export class IncomingMessage extends Readable { #httpVersionMajor = 1 #httpVersionMinor = 1 #statusMessage = null #statusCode = 0 #complete = false #context = new AsyncContext.Variable() #headers = {} #timeout = null #method = 'GET' #server = null #url = null /** * `IncomingMessage` class constructor. * @ignore * @param {object} options */ constructor (options) { super() this.#server = options?.server ?? null if (options?.headers && typeof options?.headers === 'object') { const { headers } = options if (Array.isArray(headers)) { for (const entry of headers) { if (typeof entry === 'string') { const index = entry.indexOf(':') if (index >= 0) { const [key, value] = [ entry.slice(0, index + 1), entry.slice(index + 1) ] if (key && value) { this.#headers[key.toLowerCase()] = value } } } else if (Array.isArray(entry) && entry.length === 2) { const [key, value] = entry if ( (key && typeof key === 'string') && (value && typeof value === 'string') ) { this.#headers[key.toLowerCase()] = value } } } } else { const entries = typeof headers.entries === 'function' ? headers.entries() : Object.entries(headers) for (const [key, value] of entries) { if (key && value && typeof value === 'string') { this.#headers[key.toLowerCase()] = value } } } } // let construction decide this if (options?.complete === true) { this.#complete = true } else { this.once('complete', () => { this.#complete = true clearTimeout(this.#timeout) }) } if (options?.method && METHODS.includes(options.method)) { this.#method = options.method } if ( options?.statusCode && Number.isFinite(options.statusCode) && STATUS_CODES[options.statusCode] ) { this.#statusCode = options.statusCode this.#statusMessage = ( options.statusMessage ?? STATUS_CODES[options.statusCode] ) } if (options?.url) { this.url = options.url } } /** * @type {Server} */ get server () { return this.#server } /** * @type {AsyncContext.Variable} */ get context () { return this.#context } /** * This property will be `true` if a complete HTTP message has been received * and successfully parsed. * @type {boolean} */ get complete () { return this.#complete } /** * An object of the incoming message headers. * @type {object} */ get headers () { return this.#headers } /** * The URL for this incoming message. This value is not absolute with * respect to the protocol and hostname. It includes the path and search * query component parameters. * @type {string} */ get url () { return this.#url } set url (url) { if (typeof url === 'string') { if (URL.canParse(url)) { url = new URL(url) } } if (url instanceof URL) { const { hostname, pathname, search } = url this.#url = `${pathname}${search}` this.#headers.host = hostname } else if (typeof url === 'string') { if (!url.startsWith('/')) { url = `/${url}` } this.#url = url } else { throw new TypeError('Invalid URL given') } } /** * Similar to `message.headers`, but there is no join logic and the values * are always arrays of strings, even for headers received just once. * @type {object} */ get headersDistinct () { const headers = {} for (const key in this.#headers) { headers[key] = this.#headers[key].split(',') } return headers } /** * The HTTP major version of this request. * @type {number} */ get httpVersionMajor () { return this.#httpVersionMajor } /** * The HTTP minor version of this request. * @type {number} */ get httpVersionMinor () { return this.#httpVersionMinor } /** * The HTTP version string. * A concatenation of `httpVersionMajor` and `httpVersionMinor`. * @type {string} */ get httpVersion () { return `${this.httpVersionMajor}.${this.httpVersionMinor}` } /** * The HTTP request method. * @type {string} */ get method () { return this.#method } /** * The raw request/response headers list potentially as they were received. * @type {string[]} */ get rawHeaders () { return Array.from(Object.entries(this.#headers)).reduce((h, e) => h.concat(e), []) } /** * @ignore */ get rawTrailers () { // not supported return [] } /** * @ignore */ get socket () { return this } /** * The HTTP request status code. * Only valid for response obtained from `ClientRequest`. * @type {number} */ get statusCode () { return this.#statusCode } /** * The HTTP response status message (reason phrase). * Such as "OK" or "Internal Server Error." * Only valid for response obtained from `ClientRequest`. * @type {string?} */ get statusMessage () { return this.#statusMessage } /** * An alias for `statusCode` * @type {number} */ get status () { return this.#statusCode } /** * An alias for `statusMessage` * @type {string?} */ get statusText () { return this.#statusMessage } /** * @ignore */ get trailers () { // not supported return {} } /** * @ignore */ get trailersDistinct () { // not supported return {} } /** * Gets the value of the HTTP header with the given name. * If that header is not set, the returned value will be `undefined`. * @param {string} * @return {string|undefined} */ getHeader (name) { if (name && typeof name === 'string') { return this.#headers[name.toLowerCase()] ?? undefined } return undefined } /** * Returns an array containing the unique names of the current outgoing * headers. All names are lowercase. * @return {string[]} */ getHeaderNames () { return Array.from(Object.keys(this.#headers)) } /** * @ignore */ getRawHeaderNames () { return this.getHeaderNames() .map((name) => name.split('-').map(toProperCase).join('-')) } /** * Returns a copy of the HTTP headers as an object. * @return {object} */ getHeaders () { return Array.from(Object.entries(this.#headers)) } /** * Returns true if the header identified by name is currently set in the * outgoing headers. The header name is case-insensitive. * @param {string} name * @return {boolean} */ hasHeader (name) { if (name && typeof name === 'string') { const value = this.#headers[name.toLowerCase()] return value && typeof value === 'string' } return false } /** * Sets the incoming message timeout with an optional callback. * @param {number} timeout * @param {function=} [callback] * @return {IncomingMessage} */ setTimeout (timeout, callback = null) { if (!timeout || !Number.isFinite(timeout)) { throw new TypeError('Expecting a finite integer for a timeout') } if (this.complete) { return this } if (typeof callback === 'function') { this.once('timeout', callback) } this.#timeout = setTimeout(() => { this.emit('timeout') }, timeout) return this } } /** * An object that is created internally and returned from `request()`. * @see {@link https://nodejs.org/api/http.html#class-httpclientrequest} */ export class ClientRequest extends OutgoingMessage { #method = null #agent = null #url = null #maxHeadersCount = 2000 /** * `ClientRequest` class constructor. * @ignore * @param {object} options */ constructor (options) { super() this.#agent = options?.agent ?? null if (this.#agent) { this.#agent.requests.add(this) this.once('finish', () => { this.#agent.requests.delete(this) }) } if (options?.method && METHODS.includes(options.method)) { this.#method = options.method } if (options?.url) { let url = options.url if (typeof url === 'string') { if (URL.canParse(url)) { url = new URL(url) } else if (URL.canParse(url, location.origin)) { url = new URL(url, location.origin) } else { url = null } } if (url instanceof URL) { const { hostname, pathname, search } = url this.#url = `${pathname}${search}` this.setHeader('host', hostname) } else { throw new TypeError('Invalid URL given') } } } /** * The HTTP request method. * @type {string} */ get method () { return this.#method } /** * The request protocol * @type {string?} */ get protocol () { return this.#url?.protoocl ?? null } /** * The request path. * @type {string} */ get path () { if (!this.#url) { return null } return this.#url.pathname + this.#url.search } /** * The request host name (including port). * @type {string?} */ get host () { return this.#url?.hostname ?? null } /** * The URL for this outgoing message. This value is not absolute with * respect to the protocol and hostname. It includes the path and search * query component parameters. * @type {string} */ get url () { return this.#url } /** * @ignore * @type {boolean} */ get finished () { return this.writableEnded } /** * @ignore * @type {boolean} */ get reusedSocket () { return false } /** * @ignore * @param {boolean=} [value] * @return {ClientRequest} */ setNoDelay (value = false) { // not supported return this } /** * @ignore * @param {boolean=} [enable] * @param {number=} [initialDelay] * @return {ClientRequest} */ setSocketKeepAlive (enable = false, initialDelay = 0) { // not supported return this } } /** * An object that is created internally by a `Server` instance, not by the user. * It is passed as the second parameter to the 'request' event. * @see {@link https://nodejs.org/api/http.html#class-httpserverresponse} */ export class ServerResponse extends OutgoingMessage { #statusMessage = STATUS_CODES[200] #statusCode = 200 #request = null #sendDate = true #server = null /** * `ServerResponse` class constructor. * @param {object} options */ constructor (options) { super() this.#request = options?.request ?? null this.#server = options?.server ?? null } /** * @type {Server} */ get server () { return this.#server } /** * A reference to the original HTTP request object. * @type {IncomingMessage} */ get request () { return this.#request } /** * A reference to the original HTTP request object. * @type {IncomingMessage} */ get req () { return this.request } /** * The HTTP request status code. * Only valid for response obtained from `ClientRequest`. * @type {number} */ get statusCode () { return this.#statusCode } set statusCode (statusCode) { this.#statusCode = statusCode } /** * The HTTP response status message (reason phrase). * Such as "OK" or "Internal Server Error." * Only valid for response obtained from `ClientRequest`. * @type {string?} */ get statusMessage () { return this.#statusMessage } set statusMessage (statusMessage) { this.#statusMessage = statusMessage } /** * An alias for `statusCode` * @type {number} */ get status () { return this.#statusCode } set status (status) { this.#statusCode = status } /** * An alias for `statusMessage` * @type {string?} */ get statusText () { return this.#statusMessage } set statusText (statusText) { this.#statusMessage = statusText } /** * If `true`, the "Date" header will be automatically generated and sent in * the response if it is not already present in the headers. * Defaults to `true`. * @type {boolean} */ get sendDate () { return this.#sendDate } set sendDate (value) { if (typeof value === 'boolean') { this.#sendDate = value } } /** * @ignore */ writeContinue () { // not supported return this } /** * @ignore */ writeEarlyHints () { // not supported return this } /** * @ignore */ writeProcessing () { // not supported return this } /** * Writes the response header to the request. * The `statusCode` is a 3-digit HTTP status code, like 200 or 404. * The last argument, `headers`, are the response headers. * Optionally one can give a human-readable `statusMessage` * as the second argument. * @param {number|string} statusCode * @param {string|object|string[]} [statusMessage] * @param {object|string[]} [headers] * @return {ClientRequest} */ writeHead (statusCode, statusMessage = null, headers = null) { if ( statusMessage && (typeof statusMessage === 'object' || Array.isArray(statusMessage)) ) { headers = statusMessage statusMessage = null } this.#statusCode = parseInt(statusCode) this.#statusMessage = statusMessage ?? STATUS_CODES[statusCode] if (Array.isArray(headers)) { for (let i = 0; i < headers.length; i += 2) { const key = headers[i] const value = headers[i + 1] this.appendHeader(key, value) } } else if (headers && typeof headers === 'object') { for (const key in headers) { const value = headers[key] this.setHeader(key, value) } } return this } /** * Finishes the server response. * @param {(Buffer|Uint8Array|string|function)=} [chunk] * @param {(string|function)=} [encoding] * @param {function=} [callback] * @return {OutgoingMessage} */ end (chunk = null, encoding = null, callback = null) { if (this.#server) { for (const connection of this.#server.connections) { if (connection.response === this) { connection.close() } } } super.end(chunk, encoding, callback) return this } /** * @ignore */ _implicitHeader () { this.writeHead(this.statusCode) } } /** * An options object container for an `Agent` instance. */ export class AgentOptions { keepAlive = false timeout = -1 /** * `AgentOptions` class constructor. * @ignore * @param {{ * keepAlive?: boolean, * timeout?: number * }} [options] */ constructor (options) { this.keepAlive = options?.keepAlive === true this.timeout = Number.isFinite(options?.timeout) && options.timeout > 0 ? options.timeout : -1 } } /** * An Agent is responsible for managing connection persistence * and reuse for HTTP clients. * @see {@link https://nodejs.org/api/http.html#class-httpagent} */ export class Agent extends EventEmitter { defaultProtocol = 'http:' options = null requests = new Set() sockets = {} // unused maxFreeSockets = 256 maxTotalSockets = Infinity maxSockets = Infinity /** * `Agent` class constructor. * @param {AgentOptions=} [options] */ constructor (options = null) { super() this.options = new AgentOptions(options) } /** * @ignore */ get freeSockets () { return {} } /** * @ignore * @param {object} options */ getName (options) { const { host = '', port = '', localAddress = '', family = '' } = options ?? {} if (host && port && localAddress && family) { return [host, port, localAddress, family].join(':') } return [host, port, localAddress].join(':') } /** * Produces a socket/stream to be used for HTTP requests. * @param {object} options * @param {function(Duplex)=} [callback] * @return {Duplex} */ createConnection (options, callback = null) { let controller = null let timeout = null let url = null const abortController = new AbortController() const readable = new ReadableStream({ start (c) { controller = c } }) const pending = { callbacks: [], data: [] } const stream = new Duplex({ signal: abortController.signal, write (data, cb) { controller.enqueue(data) cb(null) }, read (cb) { if (pending.data.length) { const data = pending.data.shift() this.push(data) cb(null) } else { pending.callbacks.push(cb) } } }) stream.on('finish', () => readable.close()) url = `${options.protocol ?? this.defaultProtocol}//` url += (options.host || options.hostname) if (options.port) { url += `:${options.port}` } url += (options.path || options.pathname) if (options.signal) { options.signal.addEventListener('abort', () => { abortController.abort(options.signal.reason) }) } if (options.timeout) { timeout = setTimeout(() => { abortController.abort('Connection timed out') stream.emit('timeout') }, options.timeout) } abortController.signal.addEventListener('abort', () => { stream.emit('aborted') stream.emit('error', Object.assign(new Error('aborted'), { code: 'ECONNRESET' })) }) const deferredRequestPromise = options.makeRequest ? options.makeRequest() : Promise.resolve() deferredRequestPromise.then(makeRequest) function makeRequest (req) { const request = fetch(url, { // @ts-ignore headers: Object.fromEntries( Array.from(Object.entries( options.headers?.entries?.() ?? options.headers ?? {} )).concat(req.headers.entries()) ), signal: abortController.signal, method: options.method ?? 'GET', body: /put|post/i.test(options.method ?? '') ? readable : undefined }) if (options.handleResponse) { request.then(options.handleResponse) } request.finally(() => clearTimeout(timeout)) request .then((response) => { if (response.body) { return response.body.getReader() } return response .blob() .then((blob) => blob.stream().getReader()) }) .then((reader) => { read() function read () { reader.read() .then(({ done, value }) => { if (pending.callbacks.length) { const cb = pending.callbacks.shift() stream.push(value) cb(null) } else { pending.data.push(value ?? null) } if (!done) { read() } }) } }) if (typeof callback === 'function') { callback(stream) } } return stream } /** * @ignore */ keepSocketAlive () { // not supported } /** * @ignore */ reuseSocket () { // not supported } /** * @ignore */ destroy () { for (const request of this.requests) { // @ts-ignore if (typeof request?.destroy === 'function') { // @ts-ignore request.destroy() } } } } /** * The global and default HTTP agent. * @type {Agent} */ export const globalAgent = new Agent() /** * A duplex stream between a HTTP request `IncomingMessage` and the * response `ServerResponse` */ export class Connection extends Duplex { server = null active = false request = null response = null /** * `Connection` class constructor. * @ignore * @param {Server} server * @param {IncomingMessage} incomingMessage * @param {ServerResponse} serverResponse */ constructor (server, incomingMessage, serverResponse) { super({ read (cb) { try { this.push(incomingMessage.read()) cb(null) } catch (err) { cb(err) } }, write (data, cb) { try { serverResponse.write(data) cb(null) } catch (err) { cb(err) } } }) this.server = server this.request = incomingMessage this.response = serverResponse if (this.server.requestTimeout > 0) { const timeout = setTimeout( () => { this.response.statusCode = 408 this.response.statusMessage = STATUS_CODES[408] this.response.buffers.splice(0, this.response.buffers.length) this.response.end() this.emit('timeout') }, this.server.requestTimeout ) this.request.once('end', () => { clearTimeout(timeout) }) } if (this.server.timeout > 0) { const waitForNoActivity = () => { const timeout = setTimeout( () => { this.response.statusCode = 408 this.response.statusMessage = STATUS_CODES[408] this.response.buffers.splice(0, this.response.buffers.length) this.response.end() this.emit('timeout') }, this.server.timeout ) this.response.on('writebuffer', () => { clearTimeout(timeout) waitForNoActivity() }) } waitForNoActivity() } } /** * Closes the connection, destroying the underlying duplex, request, and * response streams. * @return {Connection} */ close () { this.destroy() } } /** * A nodejs compat HTTP server typically intended for running in a "worker" * environment. * @see {@link https://nodejs.org/api/http.html#class-httpserver} */ export class Server extends EventEmitter { #maxConnections = Infinity #connections = [] #listening = false #adapter = null #closed = false #resource = new AsyncResource('HTTPServer') #port = 0 #host = null requestTimeout = 30000 timeout = 0 // unused maxRequestsPerSocket = 0 keepAliveTimeout = 0 headersTimeout = 60000 /** * @ignore * @type {AsyncResource} */ get resource () { return this.#resource } /** * The adapter interface for this `Server` instance. * @ignore */ get adapterInterace () { return { Connection, globalAgent, IncomingMessage, METHODS, ServerResponse, STATUS_CODES } } /** * `true` if the server is closed, otherwise `false`. * @type {boolean} */ get closed () { return this.#closed } /** * The host to listen to. This value can be `null`. * Defaults to `location.hostname`. This value * is used to filter requests by hostname. * @type {string?} */ get host () { return this.#host ?? null } /** * The `port` to listen on. This value can be `0`, which is the default. * This value is used to filter requests by port, if given. A port value * of `0` does not filter on any port. * @type {number} */ get port () { return this.#port ?? 0 } /** * A readonly array of all active or inactive (idle) connections. * @type {Connection[]} */ get connections () { return Array.from(this.#connections) } /** * `true` if the server is listening for requests. * @type {boolean} */ get listening () { return this.#listening } /** * The number of concurrent max connections this server should handle. * Default: Infinity * @type {number} */ get maxConnections () { return this.#maxConnections } set maxConnections (value) { if (value && typeof value === 'number' && value > 0) { this.#maxConnections = value } } /** * Gets the HTTP server address and port that it this server is * listening (emulated) on in the runtime with respect to the * adapter internal being used by the server. * @return {{ family: string, address: string, port: number}} */ address () { return { family: 'IPv4', address: this.#host, port: this.#port } } /** * Closes the server. * @param {function=} [close] */ close (callback = null) { if (typeof callback === 'function') { this.once('close', callback) } this.closeAllConnections() this.#adapter.destroy() this.#closed = true queueMicrotask(() => this.emit('close')) } /** * Closes all connections. */ closeAllConnections () { for (const connection of this.#connections) { connection.close() } this.#connections = [] } /** * Closes all idle connections. */ closeIdleConnections () { for (const connection of this.#connections) { if (!connection.active) { connection.close() } } this.#connections = this.#connections.filter((connection) => connection.active === true ) } /** * @ignore */ setTimeout (timeout = 0, callback = null) { // not supported return this } /** * @param {number|object=} [port] * @param {string=} [host] * @param {function|null} [unused] * @param {function=} [callback * @return Server */ listen (port = 0, host = null, unused = null, callback) { if (typeof port === 'function') { callback = port port = 0 host = null unused = null } if (typeof host === 'function') { callback = host host = null unused = null } if (typeof unused === 'function') { callback = unused } if (port && typeof port === 'object') { const options = /** @type {{ hostname?: string, port?: number }} */ (port) if (typeof host === 'function') { callback = host } port = options?.port ?? 0 host = options?.host ?? location?.hostname ?? null } if (typeof callback === 'function') { this.once('listening', callback) } if (typeof port === 'number' && Number.isFinite(port) && port > 0) { this.#port = port } else { this.#port = 0 } if (host && typeof host === 'string') { this.#host = host } else { this.#host = location?.hostname ?? null } if (globalThis.isServiceWorkerScope === true) { this.#adapter = new adapters.ServiceWorkerServerAdapter( this, this.adapterInterace ) this.#adapter.addEventListener('activate', () => { this.#listening = true this.emit('listening') }) } this.on('connection', (connection) => { if (this.#connections.length < this.maxConnections) { this.#connections.push(connection) connection.response.once('finish', () => { const index = this.#connections.indexOf(connection) if (index >= 0) { this.#connections.splice(index, 1) } }) } else { connection.close() } }) gc.ref(this) return this } /** * Implements `gc.finalizer` for gc'd resource cleanup. * @return {gc.Finalizer} * @ignore */ [gc.finalizer] () { return { args: [this.#adapter], handle (adapter) { if (adapter) { adapter.destroy() } } } } } /** * Makes a HTTP request, optionally a `socket://` for relative paths when * `socket:` is the origin protocol. * @param {string|object} optionsOrURL * @param {(object|function)=} [options] * @param {function=} [callback] * @return {ClientRequest} */ async function request (optionsOrURL, options, callback) { if (typeof options === 'function') { callback = options } if (optionsOrURL && typeof optionsOrURL === 'object') { options = optionsOrURL callback = options } else if (typeof optionsOrURL === 'string') { const url = location.origin.startsWith('blob') ? new URL(optionsOrURL, new URL(location.origin).pathname) : new URL(optionsOrURL, location.origin) options = { host: url.host, port: url.port, pathname: url.pathname, protocol: url.protocol, ...options } } let agent = null if (options.agent) { agent = options.agent } else if (options.agent === false) { agent = new (options.Agent ?? Agent)() } else { agent = globalAgent } let url = `${options.protocol ?? agent?.defaultProtocol ?? 'http:'}//${options.host || options.hostname}` if (options.port) { url += `:${options.port}` } url += options.pathname ?? '/' const request = new ClientRequest({ method: options?.method ?? 'GET', agent, url }) options = { ...options, makeRequest () { return new Promise((resolve) => { if (!/post|put/i.test(options.method ?? '')) { resolve(request) } else { stream.on('finish', () => resolve(request)) } request.headersSent = true }) }, handleResponse (response) { const incomingMessage = new IncomingMessage({ statusMessage: response.statusText, statusCode: response.status, complete: true, headers: response.headers, method: request.method, url: response.url }) stream.response = response stream.pipe(incomingMessage) request.emit('response', incomingMessage) } } const stream = agent.createConnection(options, callback) stream.on('finish', () => request.emit('finish')) stream.on('timeout', () => request.emit('timeout')) return request } /** * Makes a HTTP or `socket:` GET request. A simplified alias to `request()`. * @param {string|object} optionsOrURL * @param {(object|function)=} [options] * @param {function=} [callback] * @return {ClientRequest} */ export function get (optionsOrURL, options, callback) { return request(optionsOrURL, options, callback) } /** * Creates a HTTP server that can listen for incoming requests. * Requests that are dispatched to this server depend on the context * in which it is created, such as a service worker which will use a * "fetch event" adapter. * @param {object|function=} [options] * @param {function=} [callback] * @return {Server} */ export function createServer (options = null, callback = null) { if (typeof options === 'function') { callback = options options = null } const server = new Server(options) if (typeof callback === 'function') { server.on('request', callback) } return server } export default exports