@ayonli/jsext
Version:
A JavaScript extension package for building strong and modern applications.
790 lines (731 loc) • 25.5 kB
text/typescript
/**
* Functions for handling HTTP related tasks, such as parsing headers and
* serving HTTP requests.
*
* Many functions in this module are designed to work in all environments, but
* some of them are only available in server runtimes such as Node.js, Deno,
* Bun and Cloudflare Workers.
*
* This module itself is a executable script that can be used to serve static
* files in the current working directory, or we can provide an entry module
* which has an default export that satisfies the {@link ServeOptions} to start
* a custom HTTP server.
*
* The script can be run directly with Deno, Bun, or Node.js.
*
* Deno:
* ```sh
* deno run --allow-net --allow-read jsr:@ayonli/jsext/http [--port PORT] [DIR]
* deno run --allow-net --allow-read jsr:@ayonli/jsext/http <entry.ts>
* ```
*
* Bun:
* ```sh
* bun run node_modules/@ayonli/jsext/http.ts [--port PORT] [DIR]
* bun run node_modules/@ayonli/jsext/http.ts <entry.ts>
* ```
*
* Node.js (tsx):
* ```sh
* tsx node_modules/@ayonli/jsext/http.ts [--port PORT] [DIR]
* tsx node_modules/@ayonli/jsext/http.ts <entry.ts>
* ```
*
* In Node.js, we can also do this:
*
* ```sh
* tsx --import=@ayonli/jsext/http <entry.ts> [--port PORT] [--parallel [NUM]]
* # or
* node -r @ayonli/jsext/http <entry.js> [--port PORT] [--parallel [NUM]]
* ```
* @module
* @experimental
*/
import type { Server as HttpServer } from "node:http";
import type { Http2SecureServer } from "node:http2";
import type { Worker } from "node:cluster";
import { asyncTask } from "./async.ts";
import bytes from "./bytes.ts";
import { args, parseArgs } from "./cli.ts";
import { isBun, isDeno, isNode } from "./env.ts";
import { FileInfo, createReadableStream, exists, readDir, readFile, stat } from "./fs.ts";
import { sha256 } from "./hash.ts";
import {
createRequestContext,
createTimingFunctions,
listenFetchEvent,
renderDirectoryPage,
withHeaders,
patchTimingMetrics,
withWeb as _withWeb,
} from "./http/internal.ts";
import {
BunServer,
NetAddress,
RequestContext,
RequestHandler,
RequestErrorHandler,
ServeOptions,
ServeStaticOptions,
Server
} from "./http/server.ts";
import { Range, ifMatch, ifNoneMatch, parseRange } from "./http/util.ts";
import { isMain } from "./module.ts";
import { as } from "./object.ts";
import { extname, join, resolve, startsWith } from "./path.ts";
import { readAsArray } from "./reader.ts";
import { stripStart } from "./string.ts";
import { WebSocketServer } from "./ws.ts";
/**
* @deprecated This function has been moved to `@ayonli/jsext/http/internal`.
*/
export const withWeb = _withWeb;
export * from "./http/util.ts";
export type {
NetAddress,
RequestContext,
RequestHandler,
RequestErrorHandler,
ServeOptions,
ServeStaticOptions,
Server,
};
/**
* Calculates the ETag for a given entity.
*
* @example
* ```ts
* import { stat } from "@ayonli/jsext/fs";
* import { etag } from "@ayonli/jsext/http";
*
* const etag1 = await etag("Hello, World!");
*
* const data = new Uint8Array([1, 2, 3, 4, 5]);
* const etag2 = await etag(data);
*
* const info = await stat("file.txt");
* const etag3 = await etag(info);
* ```
*/
export async function etag(data: string | Uint8Array | FileInfo): Promise<string> {
if (typeof data === "string" || data instanceof Uint8Array) {
if (!data.length) {
// a short circuit for zero length entities
return `0-47DEQpj8HBSa+/TImW+5JCeuQeR`;
}
if (typeof data === "string") {
data = bytes(data);
}
const hash = await sha256(data, "base64");
return `${data.length.toString(16)}-${hash.slice(0, 27)}`;
}
const mtime = data.mtime ?? new Date();
const hash = await sha256(mtime.toISOString(), "base64");
return `${data.size.toString(16)}-${hash.slice(0, 27)}`;
}
/**
* Returns a random port number that is available for listening.
*
* NOTE: This function is not available in the browser and worker runtimes such
* as Cloudflare Workers.
*
* @param prefer The preferred port number to return if it is available,
* otherwise a random port is returned.
*
* @param hostname The hostname to bind the port to. Default is "0.0.0.0", only
* used when `prefer` is set and not `0`.
*/
export async function randomPort(
prefer: number | undefined = undefined,
hostname: string | undefined = undefined
): Promise<number> {
hostname ||= "0.0.0.0";
if (isDeno) {
try {
const listener = Deno.listen({
hostname,
port: prefer ?? 0,
});
const { port } = listener.addr as Deno.NetAddr;
listener.close();
return Promise.resolve(port);
} catch (err) {
if (prefer) {
return randomPort(0);
} else {
throw err;
}
}
} else if (isBun) {
try {
const listener = Bun.listen({
hostname,
port: prefer ?? 0,
socket: {
data: () => { },
},
}) as { port: number; stop: (force?: boolean) => void; };
const { port } = listener;
listener.stop(true);
return Promise.resolve(port);
} catch (err) {
if (prefer) {
return randomPort(0);
} else {
throw err;
}
}
} else if (isNode) {
const { createServer, connect } = await import("net");
if (prefer) {
// In Node.js listening on a port used by another process may work,
// so we don't use `listen` method to check if the port is available.
// Instead, we use the `connect` method to check if the port can be
// reached, if so, the port is open and we don't use it.
const isOpen = await new Promise<boolean>((resolve, reject) => {
const conn = connect(prefer, hostname === "0.0.0.0" ? "localhost" : hostname);
conn.once("connect", () => {
conn.end();
resolve(true);
}).once("error", (err) => {
if ((err as any)["code"] === "ECONNREFUSED") {
resolve(false);
} else {
reject(err);
}
});
});
if (isOpen) {
return randomPort(0);
} else {
return prefer;
}
} else {
const server = createServer();
server.listen({ port: 0, exclusive: true });
const port = (server.address() as any).port as number;
return new Promise<number>((resolve, reject) => {
server.close(err => err ? reject(err) : resolve(port));
});
}
} else {
throw new Error("Unsupported runtime");
}
}
/**
* Serves HTTP requests with the given options.
*
* This function provides a unified way to serve HTTP requests in all server
* runtimes, even worker runtimes. It's similar to the `Deno.serve` and
* `Bun.serve` functions, in fact, it calls them internally when running in the
* corresponding runtime. When running in Node.js, it uses the built-in `http`
* or `http2` modules to create the server.
*
* This function also provides easy ways to handle Server-sent Events and
* WebSockets inside the fetch handler without touching the underlying verbose
* APIs.
*
* Currently, the following runtimes are supported:
*
* - Node.js (v18.4.1 or above)
* - Deno
* - Bun
* - Cloudflare Workers
* - Fastly Compute
* - Service Worker in the browser
*
* NOTE: WebSocket is not supported in Fastly Compute and browser's Service
* Worker at the moment.
*
* @example
* ```ts
* // simple http server
* import { serve } from "@ayonli/jsext/http";
*
* serve({
* fetch(req) {
* return new Response("Hello, World!");
* },
* });
* ```
*
* @example
* ```ts
* // set the hostname and port
* import { serve } from "@ayonli/jsext/http";
*
* serve({
* hostname: "localhost",
* port: 8787, // same port as Wrangler dev
* fetch(req) {
* return new Response("Hello, World!");
* },
* });
* ```
*
* @example
* ```ts
* // serve HTTPS/HTTP2 requests
* import { readFileAsText } from "@ayonli/jsext/fs";
* import { serve } from "@ayonli/jsext/http";
*
* serve({
* key: await readFileAsText("./cert.key"),
* cert: await readFileAsText("./cert.pem"),
* fetch(req) {
* return new Response("Hello, World!");
* },
* });
* ```
*
* @example
* ```ts
* // respond Server-sent Events
* import { serve } from "@ayonli/jsext/http";
*
* serve({
* fetch(req, ctx) {
* const { events, response } = ctx.createEventEndpoint();
* let count = events.lastEventId ? Number(events.lastEventId) : 0;
*
* setInterval(() => {
* const lastEventId = String(++count);
* events.dispatchEvent(new MessageEvent("ping", {
* data: lastEventId,
* lastEventId,
* }));
* }, 5_000);
*
* return response;
* },
* });
* ```
*
* @example
* ```ts
* // upgrade to WebSocket
* import { serve } from "@ayonli/jsext/http";
*
* serve({
* fetch(req, ctx) {
* const { socket, response } = ctx.upgradeWebSocket();
*
* socket.addEventListener("message", (event) => {
* console.log(event.data);
* socket.send("Hello, Client!");
* });
*
* return response;
* },
* });
* ```
*
* @example
* ```ts
* // module mode (for `deno serve`, Bun and Cloudflare Workers)
* import { serve } from "@ayonli/jsext/http";
*
* export default serve({
* type: "module",
* fetch(req) {
* return new Response("Hello, World!");
* },
* });
* ```
*/
export function serve(options: ServeOptions): Server {
const type = isDeno || isBun || isNode ? options.type || "classic" : "classic";
const ws = new WebSocketServer(options.ws);
const { fetch: handle, key, cert, onListen, headers } = options;
const onError: ServeOptions["onError"] = options.onError ?? ((err) => {
console.error(err);
return new Response("Internal Server Error", {
status: 500,
statusText: "Internal Server Error",
});
});
return new Server(async () => {
let hostname = options.hostname || "0.0.0.0";
let port = options.port;
let controller: AbortController | null = null;
let server: HttpServer | Http2SecureServer | Deno.HttpServer | BunServer | null = null;
if (isDeno) {
if (type === "classic") {
port ||= await randomPort(8000, hostname);
controller = new AbortController();
const task = asyncTask<void>();
server = Deno.serve({
hostname,
port,
key,
cert,
signal: controller.signal,
onListen: () => task.resolve(),
}, (req, info) => {
const { getTimers, time, timeEnd } = createTimingFunctions();
const ctx = createRequestContext(req, {
ws,
remoteAddress: {
family: info.remoteAddr.hostname.includes(":") ? "IPv6" : "IPv4",
address: info.remoteAddr.hostname,
port: info.remoteAddr.port,
},
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));
});
await task;
} else {
hostname = "";
port = 0;
}
} else if (isBun) {
if (type === "classic") {
const tls = key && cert ? { key, cert } : undefined;
port ||= await randomPort(8000, hostname);
server = Bun.serve({
hostname,
port,
tls,
fetch: (req: Request, server: BunServer) => {
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));
},
websocket: ws.bunListener,
}) as BunServer;
ws.bunBind(server);
} else {
hostname = "0.0.0.0";
port = 3000;
}
} else if (isNode) {
if (type === "classic") {
const reqListener = withWeb((req, info) => {
const { getTimers, time, timeEnd } = createTimingFunctions();
const ctx = createRequestContext(req, { ws, ...info, 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));
});
if (key && cert) {
const { createSecureServer } = await import("node:http2");
server = createSecureServer({ key, cert, allowHTTP1: true }, reqListener);
} else {
const { createServer } = await import("node:http");
server = createServer(reqListener);
}
port ||= await randomPort(8000, hostname);
await new Promise<void>((resolve) => {
if (hostname && hostname !== "0.0.0.0") {
(server as HttpServer | Http2SecureServer).listen(port, hostname, resolve);
} else {
(server as HttpServer | Http2SecureServer).listen(port, resolve);
}
});
} else {
hostname = "";
port = 0;
}
} else if (typeof addEventListener === "function") {
hostname = "";
port = 0;
if (type === "classic") {
listenFetchEvent({ ws, fetch: handle, onError, headers });
}
} else {
throw new Error("Unsupported runtime");
}
return { http: server, hostname, port, controller };
}, { type, fetch: handle, onError, onListen, ws, headers, secure: !!key && !!cert });
}
/**
* Serves static files from a file system directory or KV namespace (in
* Cloudflare Workers).
*
* NOTE: In Node.js, this function requires Node.js v18.4.1 or above.
*
* NOTE: In Cloudflare Workers, this function requires setting the
* `[site].bucket` option in the `wrangler.toml` file.
*
* @example
* ```ts
* import { serve, serveStatic } from "@ayonli/jsext/http";
*
* // use `serve()` so this program runs in all environments
* serve({
* async fetch(req: Request, ctx) {
* const { pathname } = new URL(req.url);
*
* if (pathname.startsWith("/assets")) {
* return await serveStatic(req, {
* fsDir: "./assets",
* kv: ctx.bindings?.__STATIC_CONTENT,
* urlPrefix: "/assets",
* });
* }
*
* return new Response("Hello, World!");
* }
* });
* ```
*
* @example
* ```toml
* # wrangler.toml
* [site]
* bucket = "./assets"
* ```
*/
export async function serveStatic(
req: Request,
options: ServeStaticOptions = {}
): Promise<Response> {
const extraHeaders = options.headers ?? {};
const dir = options.fsDir ?? ".";
const prefix = options.urlPrefix ? join(options.urlPrefix) : "";
const url = new URL(req.url);
const pathname = decodeURIComponent(url.pathname);
if (prefix && !startsWith(pathname, prefix)) {
return new Response("Not Found", {
status: 404,
statusText: "Not Found",
headers: extraHeaders,
});
}
const filename = join(dir, stripStart(pathname.slice(prefix.length), "/"));
let info: FileInfo;
try {
info = await stat(filename);
} catch (err) {
if (as(err, Error)?.name === "NotFoundError") {
return new Response(`Not Found`, {
status: 404,
statusText: "Not Found",
headers: extraHeaders,
});
} else if (as(err, Error)?.name === "NotAllowedError") {
return new Response("Forbidden", {
status: 403,
statusText: "Forbidden",
headers: extraHeaders,
});
} else {
return new Response("Internal Server Error", {
status: 500,
statusText: "Internal Server Error",
headers: extraHeaders,
});
}
}
if (info.kind === "directory") {
if (!req.url.endsWith("/")) {
return Response.redirect(req.url + "/", 301);
} else {
if (await exists(join(filename, "index.html"))) {
return serveStatic(new Request(join(req.url, "index.html"), req), options);
} else if (await exists(join(filename, "index.htm"))) {
return serveStatic(new Request(join(req.url, "index.htm"), req), options);
} else if (options.listDir) {
const entries = await readAsArray(readDir(filename));
return renderDirectoryPage(pathname, entries, extraHeaders);
} else {
return new Response("Forbidden", {
status: 403,
statusText: "Forbidden",
headers: extraHeaders,
});
}
}
} else if (info.kind !== "file") {
return new Response("Forbidden", {
status: 403,
statusText: "Forbidden",
headers: extraHeaders,
});
}
const rangeValue = req.headers.get("Range");
let range: Range | undefined;
if (rangeValue && info.size) {
try {
range = parseRange(rangeValue);
} catch {
return new Response("Invalid Range header", {
status: 416,
statusText: "Range Not Satisfiable",
headers: extraHeaders,
});
}
}
const mtime = info.mtime ?? new Date();
const _etag = await etag(info);
const headers = new Headers({
...extraHeaders,
"Accept-Ranges": "bytes",
"Last-Modified": mtime.toUTCString(),
"Etag": _etag,
});
const ifModifiedSinceValue = req.headers.get("If-Modified-Since");
const ifNoneMatchValue = req.headers.get("If-None-Match");
const ifMatchValue = req.headers.get("If-Match");
let modified = true;
if (ifModifiedSinceValue) {
const date = new Date(ifModifiedSinceValue);
modified = Math.floor(mtime.valueOf() / 1000) > Math.floor(date.valueOf() / 1000);
} else if (ifNoneMatchValue) {
modified = ifNoneMatch(ifNoneMatchValue, _etag);
}
if (!modified) {
return new Response(null, {
status: 304,
statusText: "Not Modified",
headers,
});
} else if (ifMatchValue && range && !ifMatch(ifMatchValue, _etag)) {
return new Response("Precondition Failed", {
status: 412,
statusText: "Precondition Failed",
headers,
});
}
if (/^text\/|^application\/(json|yaml|toml|xml|javascript)$/.test(info.type)) {
headers.set("Content-Type", info.type + "; charset=utf-8");
} else {
headers.set("Content-Type", info.type || "application/octet-stream");
}
if (info.atime) {
headers.set("Date", info.atime.toUTCString());
}
if (options.maxAge) {
headers.set("Cache-Control", `public, max-age=${options.maxAge}`);
}
if (range) {
const { ranges, suffix: suffixLength } = range;
let start: number;
let end: number;
if (ranges.length) {
({ start } = ranges[0]!);
end = Math.min(ranges[0]!.end ?? info.size - 1, info.size - 1);
} else {
start = Math.max(info.size - suffixLength!, 0);
end = info.size - 1;
}
const data = await readFile(filename);
const slice = data.subarray(start, end + 1);
headers.set("Content-Range", `bytes ${start}-${end}/${info.size}`);
headers.set("Content-Length", String(end - start + 1));
return new Response(slice, {
status: 206,
statusText: "Partial Content",
headers,
});
} else if (!info.size) {
headers.set("Content-Length", "0");
return new Response("", {
status: 200,
statusText: "OK",
headers,
});
} else {
headers.set("Content-Length", String(info.size));
return new Response(createReadableStream(filename), {
status: 200,
statusText: "OK",
headers,
});
}
}
declare const Bun: any;
async function startServer(args: string[]) {
const options = parseArgs(args, {
alias: { p: "port" }
});
const port = Number.isFinite(options["port"]) ? options["port"] as number : undefined;
const parallel = options["parallel"] as boolean | number | undefined;
let config: Partial<ServeOptions> = {};
let fetch: ServeOptions["fetch"];
let filename = String(options[0] || ".");
const ext = extname(filename);
if (/^\.m?(js|ts)x?/.test(ext)) { // custom entry file
filename = resolve(filename);
const mod = await import(filename);
if (typeof mod.default === "object" && typeof mod.default.fetch === "function") {
config = mod.default as ServeOptions;
fetch = config.fetch!;
} else {
throw new Error(
"The entry file must have an `export default { fetch }` statement");
}
}
fetch ||= (req) => serveStatic(req, {
fsDir: filename,
listDir: true,
});
if (isNode) {
import("node:os").then(async ({ availableParallelism }) => {
const { default: cluster } = await import("node:cluster");
if (cluster.isPrimary && parallel) {
const _port = port || await randomPort(8000);
const max = typeof parallel === "number" ? parallel : availableParallelism();
const workers: (Worker | null)[] = new Array(max).fill(null);
const forkWorker = (i: number) => {
const worker = cluster.fork({
HTTP_PORT: String(_port),
});
workers[i] = worker;
worker.once("exit", (code) => {
workers[i] = null;
if (code) {
forkWorker(i);
}
});
};
for (let i = 0; i < max; i++) {
forkWorker(i);
}
} else if (cluster.isWorker && process.env["HTTP_PORT"]) {
serve({
...config,
fetch,
port: Number(process.env["HTTP_PORT"]!),
type: "classic",
});
} else {
serve({ ...config, fetch, port, type: "classic" });
}
});
} else {
serve({ ...config, fetch, port, type: "classic" });
}
}
if ((isDeno || isBun || isNode) && isMain(import.meta)) {
startServer(args);
} else if (isNode && process.execArgv.some(arg => arg.endsWith("@ayonli/jsext/http"))) {
const options = parseArgs(process.execArgv, {
alias: { r: "require" },
lists: ["require", "import"],
});
const args = process.argv.slice(1);
if (args.length && (
(options["require"] as string[] | undefined)?.includes("@ayonli/jsext/http") ||
(options["import"] as string[] | undefined)?.includes("@ayonli/jsext/http")
)) {
startServer(args);
}
}