UNPKG

@ayonli/jsext

Version:

A JavaScript extension package for building strong and modern applications.

499 lines (455 loc) 18.1 kB
import type { Server as HttpServer } from "node:http"; import type { Http2SecureServer } from "node:http2"; import type { serve, serveStatic } from "../http.ts"; import type { EventEndpoint } from "../sse.ts"; import { until } from "../async.ts"; import { isBun, isDeno, isNode } from "../env.ts"; import runtime, { env } from "../runtime.ts"; import { WebSocketConnection, WebSocketHandler, WebSocketServer } from "../ws.ts"; import { KVNamespace } from "../workerd/types.ts"; import { createRequestContext, createTimingFunctions, listenFetchEvent, withHeaders, patchTimingMetrics } from "./internal.ts"; export interface BunServer { fetch(request: Request | string): Response | Promise<Response>; ref(): void; requestIP(request: Request): { family: "IPv4" | "IPv6"; address: string; port: number; } | null; stop(closeActiveConnections?: boolean): void; unref(): void; upgrade<T = undefined>( request: Request, options?: { data?: T; headers?: HeadersInit; }, ): boolean; readonly development: boolean; readonly hostname: string; readonly id: string; readonly pendingRequests: number; readonly pendingWebSockets: number; readonly port: number; readonly url: URL; } export interface FetchEvent extends Event { request: Request; respondWith(response: Response | Promise<Response>): void; waitUntil?(promise: Promise<unknown>): void; client?: { address: string; }; } /** * Represents the network address of a connection peer. */ export interface NetAddress { family: "IPv4" | "IPv6"; /** * The IP address of the remote peer. */ address: string; /** * The port number of the remote peer, or `0` if it's not available. */ port: number; } /** * Represents the context of an HTTP request. It provides additional information * about the request and allows for upgrading the connection to a WebSocket. */ export interface RequestContext { /** * The remote address of the client. This property may not be available in * worker environments (such as Cloudflare Workers) or when the server is * started via `deno serve`. */ remoteAddress: NetAddress | null; /** * Creates an SSE (server-sent events) endpoint for sending events to the * client. */ createEventEndpoint(): { events: EventEndpoint<Request>; response: Response; }; /** * Upgrades the request to a WebSocket connection. */ upgradeWebSocket(): { socket: WebSocketConnection; response: Response; }; /** * Starts a timer that can be used to compute the duration of an operation * identified by a unique `name`. When the operation completes, call * `timeEnd()` with the same name to stop the timer. * * This function is similar to the `console.time`, except it logs the * duration to the `Server-Timing` header of the response and will be * displayed in the browser's devtools. * * Optionally, we can provide a `description` that will be used as the title * when displaying the timing metrics. * * We could use a `total` label to measure the total time spent, which has * special meaning in the Google Chrome browser. However, it may not be * accurate since multiple operations can happen at the same time * concurrently. * * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Server-Timing */ time(name: string, description?: string): void; /** * Stops a timer that was previously started by calling `time()` with the * same `name`. */ timeEnd(name: string): void; /** * Prolongs the request's lifetime until the promise is resolved. Only * available in workers environments such as Cloudflare Workers. */ waitUntil?: ((promise: Promise<unknown>) => void) | undefined; /** * The bindings of the request, only available in Cloudflare Workers. */ bindings?: { [x: string]: any; __STATIC_CONTENT?: KVNamespace; } | undefined; } /** * The handler for processing HTTP requests. */ export type RequestHandler = (request: Request, ctx: RequestContext) => Response | Promise<Response>; /** * The handler for processing errors happened during processing HTTP requests. */ export type RequestErrorHandler = (error: unknown, request: Request, ctx: RequestContext) => Response | Promise<Response>; /** * Options for serving HTTP requests, used by {@link serve}. */ export interface ServeOptions { /** * Instructs how the server should be deployed. `classic` means {@link serve} * will start the server itself (or use `addEventListener("fetch")` in * service workers), while `module` means using the {@link Server} instance * as an ES module with the syntax `export default serve({ ... })`. * * NOTE: This option is only adjustable in Node.js, Deno, Bun and Cloudflare * Workers, in other environments, it will be ignored and will default to * `classic`. It is recommended to set this option to `module` in Cloudflare * Workers. * * @default "classic" */ type?: "classic" | "module"; /** * The handler for processing HTTP requests. */ fetch: RequestHandler; /** * The hostname to listen on. Default is `0.0.0.0`. * * NOTE: This option is ignored in workers or when the `type` is `module`. */ hostname?: string | undefined; /** * The port to listen on. If not set, the server will first try to use the * `8000` port, and if it's not available, it will use a random port. * * NOTE: This option is ignored in workers or when the `type` is `module`. */ port?: number | undefined; /** * The certificate key for serving HTTPS/HTTP2 requests. * * NOTE: This option is ignored in workers or when the `type` is `module`. */ key?: string | undefined; /** * The certificate for serving HTTPS/HTTP2 requests. * * NOTE: This option is ignored in workers or when the `type` is `module`. */ cert?: string | undefined; /** * The WebSocket handler for processing WebSocket connections. Normally this * options is not set and the WebSocket is handled per request inside the * `fetch` handler. */ ws?: WebSocketHandler | undefined; /** * A listener that will be called when the `fetch` handler throws an error. * By default, the server will respond with a `500 Internal Server Error` * response, we can override this behavior by setting this option. */ onError?: RequestErrorHandler | undefined; /** * A listener that will be called when the server starts listening. By * default, the server will log the address it's listening on, we can * override this behavior by setting this option. * * NOTE: This option is ignored in workers or when the `type` is `module`. */ onListen?: ((info: { hostname: string; port: number; }) => void) | undefined; /** * Extra headers to be sent with the response. These headers are only set * when they're not present. * * By default, the server will set the `Server` header to the runtime name * and its version. We can set this option to override the default behavior, * or set it to `null` to disable the default headers. */ headers?: HeadersInit | null | undefined; } /** * Options for serving static files, used by {@link serveStatic}. */ export interface ServeStaticOptions { /** * The file system directory to serve files from. If not set, the current * working directory will be used. This option is not available in * Cloudflare Workers, set `kv` instead. */ fsDir?: string; /** * A KV namespace in Cloudflare Workers where the static files are stored. * This option is only needed in Cloudflare Workers, usually obtained from * the `__STATIC_CONTENT` binding. */ kv?: KVNamespace; /** * The prefix that will be stripped from the URL pathname. */ urlPrefix?: string; /** * Whether to list the directory entries when the URL pathname is a * directory. If not set, a 403 Forbidden response will be returned. */ listDir?: boolean; /** * The maximum age in seconds for the "Cache-Control" header. */ maxAge?: number; /** * Extra headers to be sent with the response. */ headers?: HeadersInit; } const _hostname = Symbol.for("hostname"); const _port = Symbol.for("port"); const _http = Symbol.for("http"); const _controller = Symbol.for("controller"); /** * A unified HTTP server interface. */ export class Server { readonly type: "classic" | "module"; private [_hostname] = "0.0.0.0"; private [_port] = 0; private [_http]: Promise<HttpServer | Http2SecureServer | Deno.HttpServer | BunServer | null>; private [_controller]: AbortController | null = null; /** * A request handler for using the server instance as an ES module worker, * only available when the server type is `module`. */ fetch?: ((request: Request, env?: any, ctx?: any) => Response | Promise<Response>); constructor(impl: () => Promise<{ http: HttpServer | Http2SecureServer | Deno.HttpServer | BunServer | null; hostname: string; port: number; controller: AbortController | null; }>, options: Pick<ServeOptions, "type" | "fetch" | "onError" | "onListen" | "headers"> & { ws: WebSocketServer; secure?: boolean; }) { this.type = options.type ?? "classic"; const { fetch: handle, ws, headers, secure } = options; const onError = options.onError ?? ((err) => { console.error(err); return new Response("Internal Server Error", { status: 500, statusText: "Internal Server Error", }); }); const defaultOnListen: ServeOptions["onListen"] = ({ hostname, port }) => { const _hostname = hostname === "0.0.0.0" ? "localhost" : hostname; const protocol = secure ? "https" : "http"; console.log(`Server listening on ${protocol}://${_hostname}:${port}`); }; const onListen = this.type === "classic" ? (options.onListen ?? defaultOnListen) : defaultOnListen; this[_http] = impl().then(({ http, hostname, port, controller }) => { this[_hostname] = hostname; this[_port] = port; this[_controller] = controller; if (http || isBun) { onListen({ hostname, port }); } return http; }); if (isDeno) { if (this.type === "classic") { delete this.fetch; } else { this.fetch = (req) => { const { getTimers, time, timeEnd } = createTimingFunctions(); const ctx = createRequestContext(req, { ws, remoteAddress: null, time, timeEnd, }); const _handle = withHeaders(handle, headers); const _onError = withHeaders(onError, headers); return _handle(req, ctx) .then(res => patchTimingMetrics(res, getTimers())) .catch(err => _onError(err, req, ctx)); }; } } else if (isBun) { if (this.type === "classic") { delete this.fetch; } else { this.fetch = (req, server: BunServer) => { ws.bunBind(server); const { getTimers, time, timeEnd } = createTimingFunctions(); const ctx = createRequestContext(req, { ws, remoteAddress: server.requestIP(req), time, timeEnd, }); const _handle = withHeaders(handle, headers); const _onError = withHeaders(onError, headers); return _handle(req, ctx) .then(res => patchTimingMetrics(res, getTimers())) .catch(err => _onError(err, req, ctx)); }; Object.assign(this, { // Bun specific properties websocket: ws.bunListener, }); } } else if (isNode) { if (this.type === "classic") { delete this.fetch; } else { this.fetch = (req, ctx: RequestContext) => { const _handle = withHeaders(handle, headers); const _onError = withHeaders(onError, headers); return _handle(req, ctx) .catch(err => _onError(err, req, ctx)); }; } } else if (typeof addEventListener === "function") { if (this.type === "classic") { let bindings: any; if (runtime().identity === "workerd") { bindings = {}; Object.keys(globalThis).forEach((key) => { if (/^[A-Z][A-Z0-9_]*$/.test(key)) { // @ts-ignore bindings[key] = globalThis[key]; } }); env(bindings as object); } listenFetchEvent({ ws, fetch: handle, onError, headers, bindings }); } else { this.fetch = (req, bindings, _ctx) => { if (bindings && typeof bindings === "object" && !Array.isArray(bindings)) { env(bindings as object); } const address = req.headers.get("cf-connecting-ip"); const { getTimers, time, timeEnd } = createTimingFunctions(); const ctx = createRequestContext(req, { ws, remoteAddress: address ? { family: address.includes(":") ? "IPv6" : "IPv4", address: address, port: 0, } : null, time, timeEnd, waitUntil: _ctx.waitUntil?.bind(_ctx), bindings, }); const _handle = withHeaders(handle, headers); const _onError = withHeaders(onError, headers); return _handle(req, ctx) .then(res => patchTimingMetrics(res, getTimers())) .catch(err => _onError(err, req, ctx)); }; } } } /** * The hostname of which the server is listening on, only available after * the server is ready and the server type is `classic`. */ get hostname(): string { return this[_hostname] || ""; } /** * The port of which the server is listening on, only available after the * server is ready and the server type is `classic`. */ get port(): number { return this[_port] || (isBun && this.type === "module" ? 3000 : 0); } /** * A promise that resolves when the server is ready to accept connections. */ get ready(): Promise<this> { return this[_http].then(() => this); } /** * Closes the server and stops it from accepting new connections. By default, * this function will wait until all active connections to close before * shutting down the server. However, we can force the server to close all * active connections and shutdown immediately by setting the `force` * parameter to `true`. * * NOTE: In Node.js, the `force` parameter is only available for HTTP * servers, it has no effect on HTTP2 servers. */ async close(force = false): Promise<void> { const server = await this[_http] as any; if (!server) return; if (typeof server.stop === "function") { const _server = server as BunServer; _server.stop(force); if (!force) { await until(() => !_server.pendingRequests && !_server.pendingWebSockets); } } else if (typeof server.shutdown === "function") { const _server = server as Deno.HttpServer; if (force && this[_controller]) { this[_controller].abort(); } else { _server.shutdown(); } await _server.finished; } else if (typeof server.close === "function") { const _server = server as HttpServer | Http2SecureServer; await new Promise<void>((resolve, reject) => { _server.close((err) => err ? reject(err) : resolve()); if (force && "closeAllConnections" in _server) { _server.closeAllConnections(); } }); } } /** * Opposite of `unref()`, calling `ref()` on a previously `unref`ed server * will _not_ let the program exit if it's the only server left (the default * behavior). If the server is `ref`ed calling `ref()` again will have no * effect. */ ref(): void { this[_http].then(server => server?.ref?.()); } /** * Calling `unref()` on a server will allow the program to exit if this is * the only active server in the event system. If the server is already * `unref`ed calling`unref()` again will have no effect. */ unref(): void { this[_http].then(server => server?.unref?.()); } }