@ayonli/jsext
Version:
A JavaScript extension package for building strong and modern applications.
395 lines (391 loc) • 12.9 kB
JavaScript
;
var array = require('../array.js');
var path = require('../path.js');
var runtime = require('../runtime.js');
var sse = require('../sse.js');
var string = require('../string.js');
/**
* This is an internal module that provides utility functions for handling HTTP
* requests, mostly used by the `http.serve` and `http.serveStatic` functions.
*
* This module is exposed for advanced use cases such as when we want to
* implement a new `serve` function that behave like the existing one, e.g. for
* integrating with Vite dev server.
*
* @module
* @experimental
*/
function sanitizeTimers(timers) {
const total = timers.get("total");
const _timers = new Map([...timers].filter(([name, metrics]) => !!metrics.timeEnd && name !== "total"));
if (!!(total === null || total === void 0 ? void 0 : total.timeEnd)) {
_timers.set("total", total);
}
return _timers;
}
/**
* Creates timing functions for measuring the request processing time. This
* function returns the timing functions and a `timers` map that associates
* with them.
*/
function createTimingFunctions() {
const timers = new Map();
return {
timers,
getTimers: (sanitize = false) => {
return sanitize ? sanitizeTimers(timers) : timers;
},
time: (name, description) => {
if (timers.has(name)) {
console.warn(`Timer '${name}' already exists`);
}
else {
timers.set(name, { timeStart: Date.now(), description });
}
},
timeEnd: (name) => {
const metrics = timers.get(name);
if (metrics) {
metrics.timeEnd = Date.now();
}
else {
console.warn(`Timer '${name}' does not exist`);
}
},
};
}
/**
* Creates a request context object from the given `request` and properties.
*/
function createRequestContext(request, props) {
const { ws, remoteAddress = null, ...rest } = props;
return {
remoteAddress,
createEventEndpoint: () => {
const events = new sse.EventEndpoint(request);
return { events, response: events.response };
},
upgradeWebSocket: () => ws.upgrade(request),
...rest,
};
}
/**
* Patches the timing metrics to the response's headers.
*/
function patchTimingMetrics(response, timers) {
const metrics = [...sanitizeTimers(timers)].map(([name, metrics]) => {
const duration = metrics.timeEnd - metrics.timeStart;
let value = `${name};dur=${duration}`;
if (metrics.description) {
value += `;desc="${metrics.description}"`;
}
else if (name === "total") {
value += `;desc="Total"`;
}
return value;
}).join(", ");
if (metrics) {
try {
response.headers.set("Server-Timing", metrics);
}
catch (_a) {
// Ignore
}
}
return response;
}
/**
* Returns a new request handler that wraps the given one so that we can add
* extra `headers` to the response.
*/
function withHeaders(handle, headers = undefined) {
if (headers === undefined) {
const { identity, version } = runtime.default();
let serverName = ({
"node": "Node.js",
"deno": "Deno",
"bun": "Bun",
"workerd": "Cloudflare Workers",
"fastly": "Fastly Compute",
})[identity] || "Unknown";
if (version) {
serverName += `/${version}`;
}
headers = { "Server": serverName };
}
return async (...args) => {
const response = await handle(...args);
if (response.status === 101) {
// WebSocket headers cannot be modified
return response;
}
try {
const patch = (name, value) => {
if (!response.headers.has(name)) {
response.headers.set(name, value);
}
};
if (headers instanceof Headers) {
headers.forEach((value, name) => patch(name, value));
}
else if (Array.isArray(headers)) {
headers.forEach(([name, value]) => patch(name, value));
}
else if (headers !== null) {
Object.entries(headers).forEach(([name, value]) => patch(name, value));
}
}
catch (_a) {
// In case the headers are immutable, ignore the error.
}
return response;
};
}
/**
* Adds a event listener to the `fetch` event in service workers that handles
* HTTP requests with the given options.
*/
function listenFetchEvent(options) {
const { ws, fetch, headers, onError, bindings } = options;
// @ts-ignore
addEventListener("fetch", (event) => {
var _a, _b, _c;
const { request } = event;
const address = (_a = request.headers.get("cf-connecting-ip")) !== null && _a !== void 0 ? _a : (_b = event.client) === null || _b === void 0 ? void 0 : _b.address;
const { getTimers, time, timeEnd } = createTimingFunctions();
const ctx = createRequestContext(request, {
ws,
remoteAddress: address ? {
family: address.includes(":") ? "IPv6" : "IPv4",
address: address,
port: 0,
} : null,
time,
timeEnd,
waitUntil: (_c = event.waitUntil) === null || _c === void 0 ? void 0 : _c.bind(event),
bindings,
});
const _handle = withHeaders(fetch, headers);
const _onError = withHeaders(onError, headers);
const response = _handle(request, ctx)
.then(res => patchTimingMetrics(res, getTimers()))
.catch(err => _onError(err, request, ctx));
event.respondWith(response);
});
}
/**
* Renders a directory listing page for the `pathname` with the given `entries`.
*/
async function renderDirectoryPage(pathname, entries, extraHeaders = {}) {
const list = [
...array.orderBy(entries.filter(e => e.kind === "directory"), e => e.name).map(e => e.name + "/"),
...array.orderBy(entries.filter(e => e.kind === "file"), e => e.name).map(e => e.name),
];
if (pathname !== "/") {
list.unshift("../");
}
const listHtml = list.map((name) => {
let url = path.join(pathname, name);
if (name.endsWith("/") && url !== "/") {
url += "/";
}
return string.dedent `
<li>
<a href="${url}">${name}</a>
</li>
`;
});
return new Response(string.dedent `
<!DOCTYPE HTML>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Directory listing for ${pathname}</title>
<style>
body {
font-family: system-ui;
}
</style>
</head>
<body>
<h1>Directory listing for ${pathname}</h1>
<hr>
<ul>
${listHtml.join("")}
</ul>
</body>
</html>
`, {
status: 200,
statusText: "OK",
headers: {
...extraHeaders,
"Content-Type": "text/html; charset=utf-8",
},
});
}
/**
* Creates a Node.js HTTP request listener with modern Web APIs.
*
* NOTE: This function is only available in Node.js and requires Node.js v18.4.1
* or above.
*
* @example
* ```ts
* import * as http from "node:http";
* import { withWeb } from "@ayonli/jsext/http/internal";
*
* const server = http.createServer(withWeb(async (req) => {
* return new Response("Hello, World!");
* }));
*
* server.listen(8000);
* ```
*/
function withWeb(listener) {
return async (nReq, nRes) => {
const remoteAddress = {
family: nReq.socket.remoteFamily,
address: nReq.socket.remoteAddress,
port: nReq.socket.remotePort,
};
const req = toWebRequest(nReq);
const res = await listener(req, { remoteAddress });
if (!nRes.req) { // fix for Deno and Node.js below v15.7.0
Object.assign(nRes, { req: nReq });
}
if (res && !nRes.headersSent) {
if (res.status === 101) {
// When the status code is 101, it means the server is upgrading
// the connection to a different protocol, usually to WebSocket.
// In this case, the response shall be and may have already been
// written by the request socket. So we should not write the
// response again.
return;
}
toNodeResponse(res, nRes);
}
};
}
/**
* Transforms a Node.js HTTP request to a modern `Request` object.
*/
function toWebRequest(req) {
var _a, _b;
const protocol = req.socket["encrypted"] || req.headers[":scheme"] === "https"
? "https" : "http";
const host = (_a = req.headers[":authority"]) !== null && _a !== void 0 ? _a : req.headers["host"];
const url = new URL((_b = req.url) !== null && _b !== void 0 ? _b : "/", `${protocol}://${host}`);
const headers = new Headers(Object.fromEntries(Object.entries(req.headers).filter(([key]) => {
return typeof key === "string" && !key.startsWith(":");
})));
if (req.headers[":authority"]) {
headers.set("Host", req.headers[":authority"]);
}
const controller = new AbortController();
const init = {
method: req.method,
headers,
signal: controller.signal,
};
const cache = headers.get("Cache-Control");
const mode = headers.get("Sec-Fetch-Mode");
const referrer = headers.get("Referer");
if (cache === "no-cache") {
init.cache = "no-cache";
}
else if (cache === "no-store") {
init.cache = "no-store";
}
else if (cache === "only-if-cached" && mode === "same-origin") {
init.cache = "only-if-cached";
}
else {
init.cache = "default";
}
if (mode === "no-cors") {
init.mode = "no-cors";
}
else if (mode === "same-origin") {
init.mode = "same-origin";
}
else {
init.mode = "cors";
}
if (referrer) {
init.referrer = referrer;
}
if (req.method !== "GET" && req.method !== "HEAD" && req.method !== "OPTIONS") {
const { readable, writable } = new TransformStream();
const writer = writable.getWriter();
req.on("data", (chunk) => {
writer.write(chunk);
}).once("error", (err) => {
writer.abort(err);
}).once("end", () => {
writer.close();
});
init.body = readable;
// @ts-ignore Node.js special
init.duplex = "half";
}
req.once("close", () => {
req.errored && controller.abort();
});
const request = new Request(url, init);
if (!req.headers[":authority"]) {
Object.assign(request, {
[Symbol.for("incomingMessage")]: req,
});
}
return request;
}
/**
* Pipes a modern `Response` object to a Node.js HTTP response.
*/
function toNodeResponse(res, nodeRes) {
const { status, statusText, headers } = res;
for (const [key, value] of headers) {
// Use `setHeader` to set headers instead of passing them to `writeHead`,
// it seems in Deno, the headers are not written to the response if they
// are passed to `writeHead`.
nodeRes.setHeader(string.capitalize(key, true), value);
}
if (nodeRes.req.httpVersion === "2.0") {
nodeRes.writeHead(status);
}
else {
nodeRes.writeHead(status, statusText);
}
if (!res.body) {
nodeRes.end();
}
else {
res.body.pipeTo(new WritableStream({
start(controller) {
nodeRes.once("close", () => {
controller.error();
}).once("error", (err) => {
controller.error(err);
});
},
write(chunk) {
nodeRes.write(chunk);
},
close() {
nodeRes.end();
},
abort(err) {
nodeRes.destroy(err);
},
}));
}
}
exports.createRequestContext = createRequestContext;
exports.createTimingFunctions = createTimingFunctions;
exports.listenFetchEvent = listenFetchEvent;
exports.patchTimingMetrics = patchTimingMetrics;
exports.renderDirectoryPage = renderDirectoryPage;
exports.withHeaders = withHeaders;
exports.withWeb = withWeb;
//# sourceMappingURL=internal.js.map