@ayonli/jsext
Version:
A JavaScript extension package for building strong and modern applications.
773 lines (769 loc) • 27 kB
JavaScript
;
var async = require('./async.js');
var bytes = require('./bytes.js');
var string = require('./string.js');
var env = require('./env.js');
require('./external/event-target-polyfill/index.js');
var http_userAgent = require('./http/user-agent.js');
var module$1 = require('./module.js');
var path = require('./path.js');
require('./cli/constants.js');
var cli_common = require('./cli/common.js');
var fs = require('./fs.js');
var hash = require('./hash.js');
var http_internal = require('./http/internal.js');
var http_server = require('./http/server.js');
var http_util = require('./http/util.js');
var object = require('./object.js');
var reader = require('./reader.js');
var ws = require('./ws.js');
var path_util = require('./path/util.js');
var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
/**
* 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 = http_internal.withWeb;
/**
* 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.default(data);
}
const hash$1 = await hash.sha256(data, "base64");
return `${data.length.toString(16)}-${hash$1.slice(0, 27)}`;
}
const mtime = (_a = data.mtime) !== null && _a !== void 0 ? _a : new Date();
const hash$1 = await hash.sha256(mtime.toISOString(), "base64");
return `${data.size.toString(16)}-${hash$1.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 (env.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 (env.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 (env.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 = env.isDeno || env.isBun || env.isNode ? options.type || "classic" : "classic";
const ws$1 = new ws.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 http_server.Server(async () => {
let hostname = options.hostname || "0.0.0.0";
let port = options.port;
let controller = null;
let server = null;
if (env.isDeno) {
if (type === "classic") {
port || (port = await randomPort(8000, hostname));
controller = new AbortController();
const task = async.asyncTask();
server = Deno.serve({
hostname,
port,
key,
cert,
signal: controller.signal,
onListen: () => task.resolve(),
}, (req, info) => {
const { getTimers, time, timeEnd } = http_internal.createTimingFunctions();
const ctx = http_internal.createRequestContext(req, {
ws: ws$1,
remoteAddress: {
family: info.remoteAddr.hostname.includes(":") ? "IPv6" : "IPv4",
address: info.remoteAddr.hostname,
port: info.remoteAddr.port,
},
time,
timeEnd,
});
const _handle = http_internal.withHeaders(handle, headers);
const _onError = http_internal.withHeaders(onError, headers);
return _handle(req, ctx)
.then(res => http_internal.patchTimingMetrics(res, getTimers()))
.catch(err => _onError(err, req, ctx));
});
await task;
}
else {
hostname = "";
port = 0;
}
}
else if (env.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 } = http_internal.createTimingFunctions();
const ctx = http_internal.createRequestContext(req, {
ws: ws$1,
remoteAddress: server.requestIP(req),
time,
timeEnd,
});
const _handle = http_internal.withHeaders(handle, headers);
const _onError = http_internal.withHeaders(onError, headers);
return _handle(req, ctx)
.then(res => http_internal.patchTimingMetrics(res, getTimers()))
.catch(err => _onError(err, req, ctx));
},
websocket: ws$1.bunListener,
});
ws$1.bunBind(server);
}
else {
hostname = "0.0.0.0";
port = 3000;
}
}
else if (env.isNode) {
if (type === "classic") {
const reqListener = withWeb((req, info) => {
const { getTimers, time, timeEnd } = http_internal.createTimingFunctions();
const ctx = http_internal.createRequestContext(req, { ws: ws$1, ...info, time, timeEnd });
const _handle = http_internal.withHeaders(handle, headers);
const _onError = http_internal.withHeaders(onError, headers);
return _handle(req, ctx)
.then(res => http_internal.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") {
http_internal.listenFetchEvent({ ws: ws$1, fetch: handle, onError, headers });
}
}
else {
throw new Error("Unsupported runtime");
}
return { http: server, hostname, port, controller };
}, { type, fetch: handle, onError, onListen, ws: ws$1, 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 ? path.join(options.urlPrefix) : "";
const url = new URL(req.url);
const pathname = decodeURIComponent(url.pathname);
if (prefix && !path_util.startsWith(pathname, prefix)) {
return new Response("Not Found", {
status: 404,
statusText: "Not Found",
headers: extraHeaders,
});
}
const filename = path.join(dir, string.stripStart(pathname.slice(prefix.length), "/"));
let info;
try {
info = await fs.stat(filename);
}
catch (err) {
if (((_c = object.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 = object.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 fs.exists(path.join(filename, "index.html"))) {
return serveStatic(new Request(path.join(req.url, "index.html"), req), options);
}
else if (await fs.exists(path.join(filename, "index.htm"))) {
return serveStatic(new Request(path.join(req.url, "index.htm"), req), options);
}
else if (options.listDir) {
const entries = await reader.readAsArray(fs.readDir(filename));
return http_internal.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 = http_util.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 = http_util.ifNoneMatch(ifNoneMatchValue, _etag);
}
if (!modified) {
return new Response(null, {
status: 304,
statusText: "Not Modified",
headers,
});
}
else if (ifMatchValue && range && !http_util.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 fs.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(fs.createReadableStream(filename), {
status: 200,
statusText: "OK",
headers,
});
}
}
async function startServer(args) {
const options = cli_common.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 = path.extname(filename);
if (/^\.m?(js|ts)x?/.test(ext)) { // custom entry file
filename = path.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 (env.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 ((env.isDeno || env.isBun || env.isNode) && module$1.isMain(({ url: (typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.src || new URL('http.js', document.baseURI).href)) }))) {
startServer(cli_common.args);
}
else if (env.isNode && process.execArgv.some(arg => arg.endsWith("@ayonli/jsext/http"))) {
const options = cli_common.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);
}
}
exports.parseUserAgent = http_userAgent.parseUserAgent;
exports.HTTP_METHODS = http_util.HTTP_METHODS;
exports.HTTP_STATUS = http_util.HTTP_STATUS;
exports.getCookie = http_util.getCookie;
exports.getCookies = http_util.getCookies;
exports.ifMatch = http_util.ifMatch;
exports.ifNoneMatch = http_util.ifNoneMatch;
exports.parseAccepts = http_util.parseAccepts;
exports.parseBasicAuth = http_util.parseBasicAuth;
exports.parseContentType = http_util.parseContentType;
exports.parseCookie = http_util.parseCookie;
exports.parseCookies = http_util.parseCookies;
exports.parseRange = http_util.parseRange;
exports.parseRequest = http_util.parseRequest;
exports.parseResponse = http_util.parseResponse;
exports.setCookie = http_util.setCookie;
exports.setFilename = http_util.setFilename;
exports.stringifyCookie = http_util.stringifyCookie;
exports.stringifyCookies = http_util.stringifyCookies;
exports.stringifyRequest = http_util.stringifyRequest;
exports.stringifyResponse = http_util.stringifyResponse;
exports.suggestResponseType = http_util.suggestResponseType;
exports.verifyBasicAuth = http_util.verifyBasicAuth;
exports.etag = etag;
exports.randomPort = randomPort;
exports.serve = serve;
exports.serveStatic = serveStatic;
exports.withWeb = withWeb;
//# sourceMappingURL=http.js.map