@ayonli/jsext
Version:
A JavaScript extension package for building strong and modern applications.
744 lines (741 loc) • 25.5 kB
JavaScript
import { asyncTask } from './async.js';
import bytes from './bytes.js';
import { stripStart } from './string.js';
import { isDeno, isBun, isNode } from './env.js';
import './external/event-target-polyfill/index.js';
export { parseUserAgent } from './http/user-agent.js';
import { isMain } from './module.js';
import { join, extname, resolve } from './path.js';
import './cli/constants.js';
import { parseArgs, args } from './cli/common.js';
import { stat, exists, readDir, readFile, createReadableStream } from './fs.js';
import { sha256 } from './hash.js';
import { withWeb as withWeb$1, createRequestContext, withHeaders, patchTimingMetrics, listenFetchEvent, renderDirectoryPage, createTimingFunctions } from './http/internal.js';
import { Server } from './http/server.js';
import { parseRange, ifNoneMatch, ifMatch } from './http/util.js';
export { HTTP_METHODS, HTTP_STATUS, getCookie, getCookies, parseAccepts, parseBasicAuth, parseContentType, parseCookie, parseCookies, parseRequest, parseResponse, setCookie, setFilename, stringifyCookie, stringifyCookies, stringifyRequest, stringifyResponse, suggestResponseType, verifyBasicAuth } from './http/util.js';
import { as } from './object.js';
import { readAsArray } from './reader.js';
import { WebSocketServer } from './ws.js';
import { startsWith } from './path/util.js';
/**
* 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
*/
var _a, _b;
/**
* @deprecated This function has been moved to `@ayonli/jsext/http/internal`.
*/
const withWeb = withWeb$1;
/**
* 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);
* ```
*/
async function etag(data) {
var _a;
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 = (_a = data.mtime) !== null && _a !== void 0 ? _a : 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`.
*/
async function randomPort(prefer = undefined, hostname = undefined) {
hostname || (hostname = "0.0.0.0");
if (isDeno) {
try {
const listener = Deno.listen({
hostname,
port: prefer !== null && prefer !== void 0 ? prefer : 0,
});
const { port } = listener.addr;
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 !== null && prefer !== void 0 ? prefer : 0,
socket: {
data: () => { },
},
});
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((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["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().port;
return new Promise((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!");
* },
* });
* ```
*/
function serve(options) {
var _a;
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 = (_a = options.onError) !== null && _a !== void 0 ? _a : ((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 = null;
let server = null;
if (isDeno) {
if (type === "classic") {
port || (port = await randomPort(8000, hostname));
controller = new AbortController();
const task = asyncTask();
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 || (port = await randomPort(8000, hostname));
server = Bun.serve({
hostname,
port,
tls,
fetch: (req, 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));
},
websocket: ws.bunListener,
});
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 || (port = await randomPort(8000, hostname));
await new Promise((resolve) => {
if (hostname && hostname !== "0.0.0.0") {
server.listen(port, hostname, resolve);
}
else {
server.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"
* ```
*/
async function serveStatic(req, options = {}) {
var _a, _b, _c, _d, _e, _f;
const extraHeaders = (_a = options.headers) !== null && _a !== void 0 ? _a : {};
const dir = (_b = options.fsDir) !== null && _b !== void 0 ? _b : ".";
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;
try {
info = await stat(filename);
}
catch (err) {
if (((_c = as(err, Error)) === null || _c === void 0 ? void 0 : _c.name) === "NotFoundError") {
return new Response(`Not Found`, {
status: 404,
statusText: "Not Found",
headers: extraHeaders,
});
}
else if (((_d = as(err, Error)) === null || _d === void 0 ? void 0 : _d.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;
if (rangeValue && info.size) {
try {
range = parseRange(rangeValue);
}
catch (_g) {
return new Response("Invalid Range header", {
status: 416,
statusText: "Range Not Satisfiable",
headers: extraHeaders,
});
}
}
const mtime = (_e = info.mtime) !== null && _e !== void 0 ? _e : 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;
let end;
if (ranges.length) {
({ start } = ranges[0]);
end = Math.min((_f = ranges[0].end) !== null && _f !== void 0 ? _f : 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,
});
}
}
async function startServer(args) {
const options = parseArgs(args, {
alias: { p: "port" }
});
const port = Number.isFinite(options["port"]) ? options["port"] : undefined;
const parallel = options["parallel"];
let config = {};
let 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;
fetch = config.fetch;
}
else {
throw new Error("The entry file must have an `export default { fetch }` statement");
}
}
fetch || (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 = new Array(max).fill(null);
const forkWorker = (i) => {
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 && (((_a = options["require"]) === null || _a === void 0 ? void 0 : _a.includes("@ayonli/jsext/http")) ||
((_b = options["import"]) === null || _b === void 0 ? void 0 : _b.includes("@ayonli/jsext/http")))) {
startServer(args);
}
}
export { etag, ifMatch, ifNoneMatch, parseRange, randomPort, serve, serveStatic, withWeb };
//# sourceMappingURL=http.js.map