| import { bold, gray, green, red } from "./_utils.mjs"; | ||
| function wrapFetch(server) { | ||
| const fetchHandler = server.options.fetch; | ||
| const middleware = server.options.middleware || []; | ||
| return middleware.length === 0 ? fetchHandler : (request) => callMiddleware(request, fetchHandler, middleware, 0); | ||
| let composed = server.options.fetch; | ||
| const middleware = server.options.middleware; | ||
| if (middleware) for (let i = middleware.length - 1; i >= 0; i--) { | ||
| const mw = middleware[i]; | ||
| const next = composed; | ||
| composed = (request) => mw(request, () => next(request)); | ||
| } | ||
| return composed; | ||
| } | ||
| function callMiddleware(request, fetchHandler, middleware, index) { | ||
| if (index === middleware.length) return fetchHandler(request); | ||
| return middleware[index](request, () => callMiddleware(request, fetchHandler, middleware, index + 1)); | ||
| } | ||
| const errorPlugin = (server) => { | ||
@@ -12,0 +13,0 @@ const errorHandler = server.options.error; |
@@ -21,6 +21,34 @@ function isTrustedProxy(trustProxy, remoteAddress) { | ||
| } | ||
| function firstForwardedValue(value) { | ||
| if (!value) return; | ||
| return (Array.isArray(value) ? value[0] : value).split(",")[0].trim() || void 0; | ||
| function forwardedList(value) { | ||
| if (!value) return []; | ||
| const raw = Array.isArray(value) ? value.join(",") : value; | ||
| const out = []; | ||
| for (const part of raw.split(",")) { | ||
| const entry = part.trim(); | ||
| if (entry) out.push(entry); | ||
| } | ||
| return out; | ||
| } | ||
| function resolveClientIP(trustProxy, peer, forwardedFor) { | ||
| if (!isTrustedProxy(trustProxy, peer)) return peer; | ||
| const list = forwardedList(forwardedFor); | ||
| for (let i = list.length - 1; i >= 0; i--) if (!isTrustedProxy(trustProxy, list[i])) return list[i]; | ||
| return list.length > 0 ? list[0] : peer; | ||
| } | ||
| function trustedHops(trustProxy, peer, forwardedFor) { | ||
| if (!isTrustedProxy(trustProxy, peer)) return 0; | ||
| const list = forwardedList(forwardedFor); | ||
| let hops = 1; | ||
| for (let i = list.length - 1; i >= 0; i--) { | ||
| if (!isTrustedProxy(trustProxy, list[i])) return hops; | ||
| hops++; | ||
| } | ||
| return Number.POSITIVE_INFINITY; | ||
| } | ||
| function forwardedHopValue(value, hops) { | ||
| if (hops <= 0) return; | ||
| const list = forwardedList(value); | ||
| if (list.length === 0) return; | ||
| return list[Math.max(0, list.length - hops)]; | ||
| } | ||
| const trustProxyPlugin = (server) => { | ||
@@ -35,6 +63,8 @@ const trustProxy = server.options.trustProxy; | ||
| function applyTrustedProxy(request, trustProxy) { | ||
| if (!isTrustedProxy(trustProxy, request.ip)) return; | ||
| const peer = request.ip; | ||
| const headers = request.headers; | ||
| const forwardedProto = firstForwardedValue(headers.get("x-forwarded-proto")); | ||
| const forwardedHost = firstForwardedValue(headers.get("x-forwarded-host")); | ||
| const hops = trustedHops(trustProxy, peer, headers.get("x-forwarded-for")); | ||
| if (hops === 0) return; | ||
| const forwardedProto = forwardedHopValue(headers.get("x-forwarded-proto"), hops); | ||
| const forwardedHost = forwardedHopValue(headers.get("x-forwarded-host"), hops); | ||
| if (forwardedProto || forwardedHost) { | ||
@@ -53,5 +83,5 @@ const url = new URL(request.url); | ||
| } | ||
| const forwardedFor = firstForwardedValue(headers.get("x-forwarded-for")); | ||
| if (forwardedFor) Object.defineProperty(request, "ip", { | ||
| value: forwardedFor, | ||
| const client = resolveClientIP(trustProxy, peer, headers.get("x-forwarded-for")); | ||
| if (client && client !== peer) Object.defineProperty(request, "ip", { | ||
| value: client, | ||
| enumerable: true, | ||
@@ -61,2 +91,2 @@ configurable: true | ||
| } | ||
| export { HOST_RE, firstForwardedValue, isTrustedProxy, trustProxyPlugin }; | ||
| export { HOST_RE, forwardedHopValue, resolveClientIP, trustProxyPlugin, trustedHops }; |
@@ -19,3 +19,3 @@ type URLInit = { | ||
| * - Triggering the setters or getters on other props will deoptimize to full URL parsing. | ||
| * - Changes to `searchParams` will be discarded as we don't track them. | ||
| * - Mutating `searchParams` deoptimizes to full URL parsing; changes are reflected in `search`/`href` and the same `searchParams` object is kept across deopts (native `URL` semantics). | ||
| */ | ||
@@ -22,0 +22,0 @@ declare const FastURL: { |
@@ -28,6 +28,46 @@ function lazyInherit(target, source, sourceKey) { | ||
| } | ||
| const _needsNormRE = /(?:(?:^|\/)(?:\.|\.\.|%2e|%2e\.|\.%2e|%2e%2e)(?:\/|$))|[\\^#"<>{}`\x80-\uffff]/i; | ||
| const _searchNeedsNormRE = /[#"'<>]/; | ||
| const _needsNormRE = /(?:(?:^|\/)(?:\.|\.\.|%2e|%2e\.|\.%2e|%2e%2e)(?:\/|$))|[\\^#"<>{}`\x00-\x20\x7f-\uffff]/i; | ||
| const _searchNeedsNormRE = /[#"'<>\x00-\x20\x7f-\uffff]/; | ||
| const FastURL = /* @__PURE__ */ (() => { | ||
| const NativeURL = globalThis.URL; | ||
| const NativeSearchParams = globalThis.URLSearchParams; | ||
| const FastURLSearchParams = class URLSearchParams { | ||
| #owner; | ||
| #params; | ||
| constructor(owner) { | ||
| this.#owner = owner; | ||
| } | ||
| static [Symbol.hasInstance](val) { | ||
| return val instanceof NativeSearchParams; | ||
| } | ||
| _adopt(params) { | ||
| this.#params = params; | ||
| } | ||
| get _params() { | ||
| if (!this.#params) { | ||
| const search = this.#owner.search; | ||
| this.#params ??= new NativeSearchParams(search); | ||
| } | ||
| return this.#params; | ||
| } | ||
| #mutable() { | ||
| this.#owner._url; | ||
| return this.#params; | ||
| } | ||
| append(name, value) { | ||
| this.#mutable().append(name, value); | ||
| } | ||
| set(name, value) { | ||
| this.#mutable().set(name, value); | ||
| } | ||
| delete(name, value) { | ||
| this.#mutable().delete(name, value); | ||
| } | ||
| sort() { | ||
| this.#mutable().sort(); | ||
| } | ||
| }; | ||
| lazyInherit(FastURLSearchParams.prototype, NativeSearchParams.prototype, "_params"); | ||
| Object.setPrototypeOf(FastURLSearchParams.prototype, NativeSearchParams.prototype); | ||
| Object.setPrototypeOf(FastURLSearchParams, NativeSearchParams); | ||
| const FastURL = class URL { | ||
@@ -45,3 +85,3 @@ #url; | ||
| const isOriginForm = url[0] === "/"; | ||
| if (isOriginForm && !_searchNeedsNormRE.test(url)) this.#href = url; | ||
| if (isOriginForm && !_searchNeedsNormRE.test(url)) this.#href = `http://localhost${url}`; | ||
| else this.#url = new NativeURL(isOriginForm ? `http://localhost${url}` : url); | ||
@@ -67,4 +107,4 @@ } else if (_needsNormRE.test(url.pathname) || url.search && _searchNeedsNormRE.test(url.search)) this.#url = new NativeURL(`${url.protocol || "http:"}//${url.host || "localhost"}${url.pathname}${url.search || ""}`); | ||
| this.#search = void 0; | ||
| this.#searchParams = void 0; | ||
| this.#pos = void 0; | ||
| this.#searchParams?._adopt(this.#url.searchParams); | ||
| return this.#url; | ||
@@ -111,5 +151,5 @@ } | ||
| get searchParams() { | ||
| if (this.#searchParams) return this.#searchParams; | ||
| if (this.#url) return this.#url.searchParams; | ||
| if (!this.#searchParams) this.#searchParams = new URLSearchParams(this.search); | ||
| return this.#searchParams; | ||
| return this.#searchParams = new FastURLSearchParams(this); | ||
| } | ||
@@ -116,0 +156,0 @@ get protocol() { |
| const noColor = /* @__PURE__ */ (() => { | ||
| const env = globalThis.process?.env ?? {}; | ||
| return env.NO_COLOR === "1" || env.TERM === "dumb"; | ||
| const proc = globalThis.process; | ||
| const env = proc?.env ?? {}; | ||
| if (env.FORCE_COLOR) return false; | ||
| if (env.NO_COLOR || env.TERM === "dumb") return true; | ||
| return !proc?.stdout?.isTTY; | ||
| })(); | ||
@@ -5,0 +8,0 @@ const _c = (c, r = 39) => (t) => noColor ? t : `\u001B[${c}m${t}\u001B[${r}m`; |
| function resolvePortAndHost(opts) { | ||
| const _port = opts.port ?? globalThis.process?.env.PORT ?? 3e3; | ||
| const port = typeof _port === "number" ? _port : Number.parseInt(_port, 10); | ||
| if (port < 0 || port > 65535) throw new RangeError(`Port must be between 0 and 65535 (got "${port}").`); | ||
| if (Number.isNaN(port) || port < 0 || port > 65535) throw new RangeError(`Port must be a number between 0 and 65535 (got "${_port}").`); | ||
| return { | ||
@@ -66,8 +66,12 @@ port, | ||
| if (typeof promise?.then !== "function") return; | ||
| promises.add(Promise.resolve(promise).catch(console.error).finally(() => { | ||
| promises.delete(promise); | ||
| })); | ||
| const chained = Promise.resolve(promise).catch(console.error).finally(() => { | ||
| promises.delete(chained); | ||
| }); | ||
| promises.add(chained); | ||
| }, | ||
| wait: () => { | ||
| return Promise.all(promises); | ||
| }, | ||
| get _size() { | ||
| return promises.size; | ||
| } | ||
@@ -74,0 +78,0 @@ }; |
@@ -54,2 +54,7 @@ import { Server, ServerHandler } from "srvx"; | ||
| * or upgraded from a legacy Node.js handler. | ||
| * | ||
| * A bare `export default function` is disambiguated by its declared parameter | ||
| * count (arity): `< 2` params is treated as a web `fetch` handler, `>= 2` as a | ||
| * legacy Node `(req, res)` handler. See the note at the resolution site for | ||
| * the edge cases this heuristic gets wrong and how to opt out. | ||
| */ | ||
@@ -56,0 +61,0 @@ fetch?: ServerHandler; |
+14
-10
@@ -12,12 +12,15 @@ import * as NodeHttp from "node:http"; | ||
| * | ||
| * These headers are set by the client on the wire, so they can only be trusted | ||
| * when a proxy you control sits in front and overwrites them. See | ||
| * {@link ServerOptions.trustProxy}. | ||
| * These headers are appended by every proxy on the wire, so trusting them is | ||
| * hop-aware: starting from the immediate peer and walking the forwarded chain | ||
| * right-to-left, each address in the trusted set is treated as a proxy we | ||
| * control. The first address *not* in the set is the real client (see | ||
| * {@link ServerOptions.trustProxy}). | ||
| * | ||
| * - `false` (default): never trust forwarded headers; derive protocol, host | ||
| * and client IP from the real transport only. | ||
| * - `true`: always trust forwarded headers. | ||
| * - `"loopback"`: trust only when the immediate peer is a loopback address | ||
| * (`127.0.0.0/8` or `::1`), i.e. a proxy running on the same host. | ||
| * - `string[]`: trust only when the immediate peer address is in the allowlist. | ||
| * - `true`: always trust forwarded headers (every hop is trusted, so the | ||
| * leftmost `X-Forwarded-For` entry is the client). | ||
| * - `"loopback"`: trust only hops on a loopback address (`127.0.0.0/8` or | ||
| * `::1`), i.e. a proxy running on the same host. | ||
| * - `string[]`: trust only hops whose address is in the allowlist. | ||
| */ | ||
@@ -83,3 +86,4 @@ type TrustProxyOption = boolean | "loopback" | string[]; | ||
| * | ||
| * When not provided, server with listen to all network interfaces by default. | ||
| * Default is read from the `HOST` environment variable. When neither is | ||
| * provided, the server will listen to all network interfaces by default. | ||
| * | ||
@@ -326,3 +330,3 @@ * **Important:** If you are running a server that is not expected to be exposed to the network, use `hostname: "localhost"`. | ||
| /** | ||
| * Access to Node.js native instance of request. | ||
| * The underlying web-standard `Request` backing this request. | ||
| * | ||
@@ -333,3 +337,3 @@ * See https://srvx.h3.dev/guide/node#noderequest | ||
| /** | ||
| * Access to the parsed URL | ||
| * Access to the parsed URL of this request. | ||
| */ | ||
@@ -336,0 +340,0 @@ _url?: URL; |
| import { errorPlugin, wrapFetch } from "../_chunks/_plugins.mjs"; | ||
| import { firstForwardedValue, isTrustedProxy } from "../_chunks/_trust-proxy.mjs"; | ||
| import { forwardedHopValue, resolveClientIP, trustedHops } from "../_chunks/_trust-proxy.mjs"; | ||
| function awsRequest(event, context, trustProxy) { | ||
| const sourceIp = awsEventIP(event); | ||
| const trusted = isTrustedProxy(trustProxy, sourceIp); | ||
| const req = new Request(awsEventURL(event, trusted), { | ||
| const forwardedFor = awsForwardedFor(event); | ||
| const hops = trustedHops(trustProxy, sourceIp, forwardedFor); | ||
| const req = new Request(awsEventURL(event, hops), { | ||
| method: awsEventMethod(event), | ||
@@ -18,5 +19,8 @@ headers: awsEventHeaders(event), | ||
| }; | ||
| req.ip = awsEventClientIP(event, sourceIp, trusted); | ||
| req.ip = resolveClientIP(trustProxy, sourceIp, forwardedFor); | ||
| return req; | ||
| } | ||
| function awsForwardedFor(event) { | ||
| return event.headers["X-Forwarded-For"] || event.headers["x-forwarded-for"]; | ||
| } | ||
| function awsEventMethod(event) { | ||
@@ -28,14 +32,7 @@ return event.httpMethod || event.requestContext?.http?.method || "GET"; | ||
| } | ||
| function awsEventClientIP(event, sourceIp, trusted) { | ||
| if (trusted) { | ||
| const forwarded = firstForwardedValue(event.headers["X-Forwarded-For"] || event.headers["x-forwarded-for"]); | ||
| if (forwarded) return forwarded; | ||
| } | ||
| return sourceIp; | ||
| } | ||
| function awsEventURL(event, trusted) { | ||
| function awsEventURL(event, hops) { | ||
| const path = event.path || event.rawPath; | ||
| const query = awsEventQuery(event); | ||
| const hostname = (trusted ? firstForwardedValue(event.headers["X-Forwarded-Host"] || event.headers["x-forwarded-host"]) : void 0) || event.headers.host || event.headers.Host || event.requestContext?.domainName || "."; | ||
| const protocol = (trusted ? firstForwardedValue(event.headers["X-Forwarded-Proto"] || event.headers["x-forwarded-proto"]) : void 0) === "http" ? "http" : "https"; | ||
| const hostname = forwardedHopValue(event.headers["X-Forwarded-Host"] || event.headers["x-forwarded-host"], hops) || event.headers.host || event.headers.Host || event.requestContext?.domainName || "."; | ||
| const protocol = forwardedHopValue(event.headers["X-Forwarded-Proto"] || event.headers["x-forwarded-proto"], hops) === "http" ? "http" : "https"; | ||
| return new URL(`${path}${query ? `?${query}` : ""}`, `${protocol}://${hostname}`); | ||
@@ -42,0 +39,0 @@ } |
@@ -21,3 +21,3 @@ import { errorPlugin, wrapFetch } from "../_chunks/_plugins.mjs"; | ||
| const fetchHandler = wrapFetch(this); | ||
| const waitUntil = this.waitUntil = (p) => Bunny.unstable?.waitUntil?.(p); | ||
| const waitUntil = this.waitUntil = (p) => globalThis.Bunny?.unstable?.waitUntil?.(p); | ||
| this.fetch = (request) => { | ||
@@ -24,0 +24,0 @@ Object.defineProperties(request, { |
@@ -12,2 +12,3 @@ import { errorPlugin, wrapFetch } from "../_chunks/_plugins.mjs"; | ||
| fetch; | ||
| #fetchListener; | ||
| constructor(options) { | ||
@@ -36,2 +37,3 @@ this.options = { | ||
| enumerable: true, | ||
| configurable: true, | ||
| get() { | ||
@@ -48,5 +50,7 @@ return request.headers.get("cf-connecting-ip"); | ||
| serve() { | ||
| addEventListener("fetch", (event) => { | ||
| event.respondWith(this.fetch(event.request, {}, event)); | ||
| }); | ||
| if (this.#fetchListener) return; | ||
| this.#fetchListener = (event) => { | ||
| event.respondWith(this.fetch(event.request, event.env || {}, event)); | ||
| }; | ||
| addEventListener("fetch", this.#fetchListener); | ||
| } | ||
@@ -57,2 +61,6 @@ ready() { | ||
| close() { | ||
| if (this.#fetchListener) { | ||
| removeEventListener("fetch", this.#fetchListener); | ||
| this.#fetchListener = void 0; | ||
| } | ||
| return Promise.resolve(); | ||
@@ -59,0 +67,0 @@ } |
+245
-48
| import { FastURL, lazyInherit } from "../_chunks/_url.mjs"; | ||
| import { createWaitUntil, fmtURL, printListening, resolvePortAndHost, resolveTLSOptions } from "../_chunks/_utils2.mjs"; | ||
| import { errorPlugin, gracefulShutdownPlugin, wrapFetch } from "../_chunks/_plugins.mjs"; | ||
| import { HOST_RE, firstForwardedValue, isTrustedProxy } from "../_chunks/_trust-proxy.mjs"; | ||
| import { HOST_RE, forwardedHopValue, resolveClientIP, trustedHops } from "../_chunks/_trust-proxy.mjs"; | ||
| import { createBodyTooLargeError, limitBodyStream } from "../_chunks/_body-limit.mjs"; | ||
| import nodeHTTP, { IncomingMessage, ServerResponse } from "node:http"; | ||
| import { Duplex, PassThrough, Readable, addAbortSignal } from "node:stream"; | ||
| import { pipeline } from "node:stream/promises"; | ||
| import { pipeline as pipeline$1 } from "node:stream/promises"; | ||
| import nodeHTTPS from "node:https"; | ||
@@ -18,10 +18,19 @@ import nodeHTTP2 from "node:http2"; | ||
| } | ||
| function sendNodeResponseDetached(nodeRes, webRes) { | ||
| function sendNodeResponseDetached(nodeRes, webRes, silent) { | ||
| try { | ||
| return _sendNodeResponse(nodeRes, webRes, true); | ||
| } catch (error) { | ||
| handleSendError(nodeRes, error); | ||
| handleSendError(nodeRes, error, silent); | ||
| } | ||
| } | ||
| function handleSendError(nodeRes, _error) { | ||
| function handleSendError(nodeRes, error, silent) { | ||
| if (!silent) console.error("[srvx] Failed to send response:", error); | ||
| failResponse(nodeRes); | ||
| } | ||
| function sendErrorResponse(nodeRes, error, silent) { | ||
| if (!silent) console.error("[srvx] Unhandled error in fetch handler:", error); | ||
| failResponse(nodeRes); | ||
| } | ||
| function failResponse(nodeRes) { | ||
| if (nodeRes.writableEnded) return; | ||
| if (nodeRes.headersSent) nodeRes.destroy(); | ||
@@ -94,3 +103,3 @@ else { | ||
| writeHead(nodeRes, status, statusText, headers); | ||
| pipeline(stream, nodeRes).catch(() => {}).then(() => resolve()); | ||
| pipeline$1(stream, nodeRes).catch(() => {}).then(() => resolve()); | ||
| } | ||
@@ -103,5 +112,9 @@ stream.once("error", onEarlyError); | ||
| if (nodeRes.destroyed) { | ||
| stream.cancel(); | ||
| stream.cancel().catch(() => {}); | ||
| return; | ||
| } | ||
| if (nodeRes.req?.method === "HEAD") { | ||
| stream.cancel().catch(() => {}); | ||
| return endNodeResponse(nodeRes); | ||
| } | ||
| const reader = stream.getReader(); | ||
@@ -130,5 +143,6 @@ function streamCancel(error) { | ||
| var NodeRequestURL = class extends FastURL { | ||
| constructor({ req, trusted = false }) { | ||
| constructor({ req, hops = 0 }) { | ||
| const path = req.url || "/"; | ||
| const forwardedHost = trusted ? firstForwardedValue(req.headers["x-forwarded-host"]) : void 0; | ||
| const trusted = hops > 0; | ||
| const forwardedHost = forwardedHopValue(req.headers["x-forwarded-host"], hops); | ||
| let host = (forwardedHost && HOST_RE.test(forwardedHost) ? forwardedHost : void 0) || req.headers.host || req.headers[":authority"]; | ||
@@ -138,3 +152,3 @@ if (host && !HOST_RE.test(host)) host = "_invalid_"; | ||
| else host = "localhost"; | ||
| const forwardedProto = trusted ? firstForwardedValue(req.headers["x-forwarded-proto"]) : void 0; | ||
| const forwardedProto = forwardedHopValue(req.headers["x-forwarded-proto"], hops); | ||
| const protocol = req.socket?.encrypted || forwardedProto === "https" || trusted && req.headers[":scheme"] === "https" ? "https:" : "http:"; | ||
@@ -201,2 +215,5 @@ if (path[0] === "/") { | ||
| } | ||
| _adopt(headers) { | ||
| this.#headers = headers; | ||
| } | ||
| get _headers() { | ||
@@ -251,2 +268,5 @@ if (!this.#headers) { | ||
| const kNativeRequest = /* @__PURE__ */ Symbol.for("srvx.nativeRequest"); | ||
| function bodyUnusable() { | ||
| return /* @__PURE__ */ new TypeError("Body is unusable: Body has already been read"); | ||
| } | ||
| const NodeRequest = /* @__PURE__ */ (() => { | ||
@@ -260,2 +280,3 @@ const NativeRequest = getNativeRequest(); | ||
| #bodyStream; | ||
| #bodyUsed = false; | ||
| #request; | ||
@@ -269,3 +290,4 @@ #headers; | ||
| #remoteAddress; | ||
| #trusted; | ||
| #remoteResolved = false; | ||
| #hops; | ||
| constructor(ctx) { | ||
@@ -283,17 +305,17 @@ this.#req = ctx.req; | ||
| } | ||
| #resolveTrusted() { | ||
| if (this.#trusted === void 0) { | ||
| #remoteAddr() { | ||
| if (!this.#remoteResolved) { | ||
| this.#remoteResolved = true; | ||
| this.#remoteAddress = this.#req.socket?.remoteAddress; | ||
| this.#trusted = isTrustedProxy(this.#trustProxy, this.#remoteAddress); | ||
| } | ||
| return this.#trusted; | ||
| return this.#remoteAddress; | ||
| } | ||
| #resolveHops() { | ||
| if (this.#hops === void 0) this.#hops = trustedHops(this.#trustProxy, this.#remoteAddr(), this.#req.headers["x-forwarded-for"]); | ||
| return this.#hops; | ||
| } | ||
| get ip() { | ||
| if (this.#ipResolved) return this.#ip; | ||
| this.#ipResolved = true; | ||
| if (this.#resolveTrusted()) { | ||
| const forwarded = firstForwardedValue(this.#req.headers["x-forwarded-for"]); | ||
| if (forwarded) return this.#ip = forwarded; | ||
| } | ||
| return this.#ip = this.#remoteAddress; | ||
| return this.#ip = resolveClientIP(this.#trustProxy, this.#remoteAddr(), this.#req.headers["x-forwarded-for"]); | ||
| } | ||
@@ -307,3 +329,3 @@ get method() { | ||
| req: this.#req, | ||
| trusted: this.#resolveTrusted() | ||
| hops: this.#resolveHops() | ||
| }); | ||
@@ -319,3 +341,2 @@ } | ||
| get headers() { | ||
| if (this.#request) return this.#request.headers; | ||
| return this.#headers ||= new NodeRequestHeaders(this.#req); | ||
@@ -343,7 +364,10 @@ } | ||
| } | ||
| #hasBody() { | ||
| const method = this.method; | ||
| return method !== "GET" && method !== "HEAD"; | ||
| } | ||
| get body() { | ||
| if (this.#request) return this.#request.body; | ||
| if (this.#bodyStream === void 0) { | ||
| const method = this.method; | ||
| let stream = !(method === "GET" || method === "HEAD") ? Readable.toWeb(this.#req) : null; | ||
| let stream = this.#hasBody() && !this.#bodyUsed ? Readable.toWeb(this.#req) : null; | ||
| if (stream && this.#maxRequestBodySize !== void 0) stream = limitBodyStream(stream, this.#maxRequestBodySize); | ||
@@ -354,2 +378,10 @@ this.#bodyStream = stream; | ||
| } | ||
| get bodyUsed() { | ||
| if (this.#isBodyUsed()) return true; | ||
| return this.#request ? this.#request.bodyUsed : false; | ||
| } | ||
| #isBodyUsed() { | ||
| if (!this.#bodyUsed && this.#bodyStream && Readable.isDisturbed(this.#bodyStream)) this.#bodyUsed = true; | ||
| return this.#bodyUsed; | ||
| } | ||
| #readBuffered() { | ||
@@ -359,14 +391,48 @@ return readBody(this.#req, this.#maxRequestBodySize); | ||
| text() { | ||
| if (this.#isBodyUsed()) return Promise.reject(bodyUnusable()); | ||
| if (this.#request) return this.#request.text(); | ||
| if (this.#bodyStream !== void 0) return this.#bodyStream ? new Response(this.#bodyStream).text() : Promise.resolve(""); | ||
| if (!this.#hasBody()) return Promise.resolve(""); | ||
| this.#bodyUsed = true; | ||
| if (this.#bodyStream !== void 0) try { | ||
| return new Response(this.#bodyStream).text(); | ||
| } catch (error) { | ||
| return Promise.reject(error); | ||
| } | ||
| return this.#readBuffered().then((buf) => buf.toString()); | ||
| } | ||
| json() { | ||
| if (this.#isBodyUsed()) return Promise.reject(bodyUnusable()); | ||
| if (this.#request) return this.#request.json(); | ||
| if (this.#bodyStream !== void 0) return this.text().then((text) => JSON.parse(text)); | ||
| if (!this.#hasBody()) return Promise.resolve().then(() => JSON.parse("")); | ||
| this.#bodyUsed = true; | ||
| if (this.#bodyStream !== void 0) try { | ||
| return new Response(this.#bodyStream).json(); | ||
| } catch (error) { | ||
| return Promise.reject(error); | ||
| } | ||
| return this.#readBuffered().then((buf) => JSON.parse(buf.toString())); | ||
| } | ||
| arrayBuffer() { | ||
| return this.#consumeNative("arrayBuffer"); | ||
| } | ||
| bytes() { | ||
| return this.#consumeNative("bytes"); | ||
| } | ||
| blob() { | ||
| return this.#consumeNative("blob"); | ||
| } | ||
| formData() { | ||
| return this.#consumeNative("formData"); | ||
| } | ||
| #consumeNative(method) { | ||
| if (this.#isBodyUsed()) return Promise.reject(bodyUnusable()); | ||
| try { | ||
| return this._request[method](); | ||
| } catch (error) { | ||
| return Promise.reject(error); | ||
| } | ||
| } | ||
| get _request() { | ||
| if (!this.#request) { | ||
| const body = this.body; | ||
| const body = this.#isBodyUsed() ? null : this.body; | ||
| this.#request = new NativeRequest(this.url, { | ||
@@ -379,3 +445,3 @@ method: this.method, | ||
| }); | ||
| this.#headers = void 0; | ||
| this.#headers._adopt(this.#request.headers); | ||
| this.#bodyStream = void 0; | ||
@@ -391,2 +457,3 @@ } | ||
| function patchGlobalRequest() { | ||
| if (globalThis.Request._srvx) return globalThis.Request; | ||
| const NativeRequest = getNativeRequest(); | ||
@@ -404,3 +471,3 @@ const PatchedRequest = class Request extends NativeRequest { | ||
| }; | ||
| if (!globalThis.Request._srvx) globalThis.Request = PatchedRequest; | ||
| globalThis.Request = PatchedRequest; | ||
| return PatchedRequest; | ||
@@ -451,3 +518,2 @@ } | ||
| const NativeResponse = globalThis.Response; | ||
| const STATUS_CODES = globalThis.process?.getBuiltinModule?.("node:http")?.STATUS_CODES || {}; | ||
| class NodeResponse { | ||
@@ -469,3 +535,3 @@ #body; | ||
| get statusText() { | ||
| return this.#response?.statusText || this.#init?.statusText || STATUS_CODES[this.status] || ""; | ||
| return this.#response?.statusText || this.#init?.statusText || ""; | ||
| } | ||
@@ -475,4 +541,3 @@ get headers() { | ||
| if (this.#headers) return this.#headers; | ||
| const initHeaders = this.#init?.headers; | ||
| return this.#headers = initHeaders instanceof Headers ? initHeaders : new Headers(initHeaders); | ||
| return this.#headers = new Headers(this.#init?.headers); | ||
| } | ||
@@ -510,3 +575,3 @@ get ok() { | ||
| if (this.#response) body = this.#response.body; | ||
| else if (this.#body) if (this.#body instanceof ReadableStream) body = this.#body; | ||
| else if (this.#body != null) if (this.#body instanceof ReadableStream) body = this.#body; | ||
| else if (typeof this.#body === "string") { | ||
@@ -523,3 +588,3 @@ body = this.#body; | ||
| } else if (this.#body instanceof DataView) { | ||
| body = Buffer.from(this.#body.buffer); | ||
| body = Buffer.from(this.#body.buffer, this.#body.byteOffset, this.#body.byteLength); | ||
| contentLength = this.#body.byteLength; | ||
@@ -545,3 +610,3 @@ } else if (this.#body instanceof Blob) { | ||
| if (contentType && !hasContentTypeHeader) headers.push("content-type", contentType); | ||
| if (contentLength && !hasContentLength) headers.push("content-length", String(contentLength)); | ||
| if (contentLength != null && !hasContentLength) headers.push("content-length", String(contentLength)); | ||
| this.#init = void 0; | ||
@@ -676,3 +741,3 @@ this.#headers = void 0; | ||
| this.#headersWritten = true; | ||
| const headerEnd = chunk.lastIndexOf("\r\n\r\n"); | ||
| const headerEnd = chunk.indexOf("\r\n\r\n"); | ||
| if (headerEnd === -1) throw new Error("Invalid HTTP headers chunk!"); | ||
@@ -707,18 +772,41 @@ if (headerEnd < chunk.length - 4) { | ||
| var WebIncomingMessage = class extends IncomingMessage { | ||
| #socket; | ||
| constructor(req, socket) { | ||
| super(socket); | ||
| this.#socket = socket; | ||
| this.method = req.method; | ||
| const url = req._url ??= new FastURL(req.url); | ||
| this.url = url.pathname + url.search; | ||
| for (const [key, value] of req.headers.entries()) this.headers[key.toLowerCase()] = value; | ||
| this.httpVersionMajor = 1; | ||
| this.httpVersionMinor = 1; | ||
| this.httpVersion = "1.1"; | ||
| const rawHeaders = this.rawHeaders; | ||
| for (const [key, value] of req.headers.entries()) { | ||
| const lowerKey = key.toLowerCase(); | ||
| if (lowerKey === "set-cookie") continue; | ||
| this.headers[lowerKey] = value; | ||
| rawHeaders.push(key, value); | ||
| } | ||
| const setCookie = req.headers.getSetCookie?.() ?? []; | ||
| if (setCookie.length > 0) { | ||
| this.headers["set-cookie"] = setCookie; | ||
| for (const cookie of setCookie) rawHeaders.push("set-cookie", cookie); | ||
| } | ||
| if (req.method !== "GET" && req.method !== "HEAD" && !this.headers["content-length"] && !this.headers["transfer-encoding"]) this.headers["transfer-encoding"] = "chunked"; | ||
| const onData = (chunk) => { | ||
| this.push(chunk); | ||
| if (!this.push(chunk)) socket.pause(); | ||
| }; | ||
| socket.on("data", onData); | ||
| socket.once("end", () => { | ||
| this.emit("end"); | ||
| this.off("data", onData); | ||
| this.complete = true; | ||
| this.push(null); | ||
| socket.off("data", onData); | ||
| }); | ||
| } | ||
| _read(_size) { | ||
| this.#socket.resume(); | ||
| } | ||
| _destroy(_err, cb) { | ||
| cb(); | ||
| } | ||
| }; | ||
@@ -762,3 +850,3 @@ function callNodeHandler(handler, req) { | ||
| if (isMiddleware) Promise.resolve(handler(nodeReq, nodeRes, (error) => error ? reject(error) : streamPromise || resolve(webRes))).catch((error) => reject(error)); | ||
| else Promise.resolve(handler(nodeReq, nodeRes)).then(() => streamPromise || webRes); | ||
| else Promise.resolve(handler(nodeReq, nodeRes)).then(() => streamPromise || webRes).catch((error) => reject(error)); | ||
| } catch (error) { | ||
@@ -771,11 +859,29 @@ reject(error); | ||
| function getNeedDrainSymbol(res) { | ||
| if (needDrainSymbol === void 0) needDrainSymbol = Object.getOwnPropertySymbols(res).find((s) => s.description === "kNeedDrain") ?? null; | ||
| needDrainSymbol ??= Object.getOwnPropertySymbols(res).find((s) => s.description === "kNeedDrain"); | ||
| return needDrainSymbol; | ||
| } | ||
| const NULL_BODY_STATUSES = /* @__PURE__ */ new Set([ | ||
| 101, | ||
| 204, | ||
| 205, | ||
| 304 | ||
| ]); | ||
| const HOP_BY_HOP_HEADERS = /* @__PURE__ */ new Set([ | ||
| "connection", | ||
| "keep-alive", | ||
| "proxy-authenticate", | ||
| "proxy-authorization", | ||
| "te", | ||
| "trailer", | ||
| "transfer-encoding", | ||
| "upgrade" | ||
| ]); | ||
| var WebServerResponse = class extends ServerResponse { | ||
| #socket; | ||
| #socketError; | ||
| #onHeadersSent; | ||
| constructor(req, socket) { | ||
| super(req); | ||
| this.assignSocket(socket); | ||
| this.useChunkedEncodingByDefault = false; | ||
| this.once("finish", () => { | ||
@@ -796,4 +902,18 @@ socket.end(); | ||
| this.waitToFinish = this.waitToFinish.bind(this); | ||
| this.waitForResponseHead = this.waitForResponseHead.bind(this); | ||
| this.toWebResponse = this.toWebResponse.bind(this); | ||
| } | ||
| writeHead(statusCode, statusMessage, headers) { | ||
| const result = typeof statusMessage === "string" ? super.writeHead(statusCode, statusMessage, stripTransferEncoding(headers)) : super.writeHead(statusCode, stripTransferEncoding(statusMessage)); | ||
| this.#onHeadersSent?.(); | ||
| return result; | ||
| } | ||
| setHeader(name, value) { | ||
| if (typeof name === "string" && name.toLowerCase() === "transfer-encoding") return this; | ||
| return super.setHeader(name, value); | ||
| } | ||
| appendHeader(name, value) { | ||
| if (typeof name === "string" && name.toLowerCase() === "transfer-encoding") return this; | ||
| return super.appendHeader(name, value); | ||
| } | ||
| waitToFinish() { | ||
@@ -824,6 +944,33 @@ if (this.writableFinished) return Promise.resolve(); | ||
| } | ||
| waitForResponseHead() { | ||
| if (this.headersSent || this.writableEnded || this.writableFinished) return Promise.resolve(); | ||
| if (this.#socketError || this.#socket.destroyed) return Promise.reject(this.#socketError ?? prematureCloseError()); | ||
| return new Promise((resolve, reject) => { | ||
| const socket = this.#socket; | ||
| const settle = (err) => { | ||
| this.#onHeadersSent = void 0; | ||
| this.removeListener("finish", onFinish); | ||
| this.removeListener("error", onError); | ||
| socket.removeListener("error", onError); | ||
| socket.removeListener("close", onClose); | ||
| if (err) reject(err); | ||
| else resolve(); | ||
| }; | ||
| this.#onHeadersSent = () => settle(); | ||
| const onFinish = () => settle(); | ||
| const onError = (err) => settle(err); | ||
| const onClose = () => { | ||
| if (!this.headersSent && !this.writableFinished) settle(this.#socketError ?? prematureCloseError()); | ||
| }; | ||
| this.on("finish", onFinish); | ||
| this.on("error", onError); | ||
| socket.on("error", onError); | ||
| socket.on("close", onClose); | ||
| }); | ||
| } | ||
| async toWebResponse() { | ||
| await this.waitToFinish(); | ||
| await this.waitForResponseHead(); | ||
| const headers = []; | ||
| const httpHeader = this._header?.split("\r\n"); | ||
| const connectionTokens = /* @__PURE__ */ new Set(); | ||
| for (let i = 1; httpHeader && i < httpHeader.length; i++) { | ||
@@ -835,5 +982,15 @@ const sepIndex = httpHeader[i].indexOf(": "); | ||
| if (!key) continue; | ||
| const lowerKey = key.toLowerCase(); | ||
| if (lowerKey === "connection") for (const token of value.split(",")) { | ||
| const t = token.trim().toLowerCase(); | ||
| if (t) connectionTokens.add(t); | ||
| } | ||
| if (HOP_BY_HOP_HEADERS.has(lowerKey)) continue; | ||
| headers.push([key, value]); | ||
| } | ||
| return new Response(this.#socket._webResBody, { | ||
| if (connectionTokens.size > 0) { | ||
| for (let i = headers.length - 1; i >= 0; i--) if (connectionTokens.has(headers[i][0].toLowerCase())) headers.splice(i, 1); | ||
| } | ||
| const nullBody = NULL_BODY_STATUSES.has(this.statusCode); | ||
| return new Response(nullBody ? null : this.#socket._webResBody, { | ||
| status: this.statusCode, | ||
@@ -845,2 +1002,26 @@ statusText: this.statusMessage, | ||
| }; | ||
| function stripTransferEncoding(headers) { | ||
| if (!headers || typeof headers !== "object") return headers; | ||
| if (Array.isArray(headers)) { | ||
| if (headers.length > 0 && Array.isArray(headers[0])) return headers.filter(([key]) => String(key).toLowerCase() !== "transfer-encoding"); | ||
| const out = []; | ||
| for (let i = 0; i < headers.length; i += 2) { | ||
| if (String(headers[i]).toLowerCase() === "transfer-encoding") continue; | ||
| out.push(headers[i], headers[i + 1]); | ||
| } | ||
| return out; | ||
| } | ||
| let hasTransferEncoding = false; | ||
| for (const key in headers) if (key.toLowerCase() === "transfer-encoding") { | ||
| hasTransferEncoding = true; | ||
| break; | ||
| } | ||
| if (!hasTransferEncoding) return headers; | ||
| const out = {}; | ||
| for (const key in headers) { | ||
| if (key.toLowerCase() === "transfer-encoding") continue; | ||
| out[key] = headers[key]; | ||
| } | ||
| return out; | ||
| } | ||
| async function fetchNodeHandler(handler, req) { | ||
@@ -853,3 +1034,13 @@ const nodeRuntime = req.runtime?.node; | ||
| try { | ||
| await handler(nodeReq, nodeRes); | ||
| if (handler.length > 2) await new Promise((resolve, reject) => { | ||
| nodeRes.once("finish", () => resolve()); | ||
| nodeRes.once("close", () => resolve()); | ||
| nodeRes.once("error", (error) => reject(error)); | ||
| Promise.resolve(handler(nodeReq, nodeRes, (error) => { | ||
| if (error) return reject(error); | ||
| if (!nodeRes.writableEnded) nodeRes.end(); | ||
| resolve(); | ||
| })).catch((error) => reject(error)); | ||
| }); | ||
| else await handler(nodeReq, nodeRes); | ||
| return await nodeRes.toWebResponse(); | ||
@@ -932,4 +1123,9 @@ } catch (error) { | ||
| request.waitUntil = this.#wait?.waitUntil; | ||
| const res = fetchHandler(request); | ||
| return res instanceof Promise ? res.then((resolvedRes) => sendNodeResponseDetached(nodeRes, resolvedRes)) : sendNodeResponseDetached(nodeRes, res); | ||
| let res; | ||
| try { | ||
| res = fetchHandler(request); | ||
| } catch (error) { | ||
| return sendErrorResponse(nodeRes, error, this.options.silent); | ||
| } | ||
| return res instanceof Promise ? res.then((resolvedRes) => sendNodeResponseDetached(nodeRes, resolvedRes, this.options.silent), (error) => sendErrorResponse(nodeRes, error, this.options.silent)) : sendNodeResponseDetached(nodeRes, res, this.options.silent); | ||
| }; | ||
@@ -954,2 +1150,3 @@ this.node = { | ||
| exclusive: !this.options.reusePort, | ||
| reusePort: this.options.reusePort, | ||
| ...tls, | ||
@@ -956,0 +1153,0 @@ ...this.options.node |
@@ -15,2 +15,3 @@ import { errorPlugin, wrapFetch } from "../_chunks/_plugins.mjs"; | ||
| #listeningPromise; | ||
| #registration; | ||
| constructor(options) { | ||
@@ -45,13 +46,15 @@ this.options = { | ||
| }).then((registration) => { | ||
| if (registration.active) location.replace(location.href); | ||
| else registration.addEventListener("updatefound", () => { | ||
| location.replace(location.href); | ||
| }); | ||
| this.#registration = registration; | ||
| if (navigator.serviceWorker.controller) return; | ||
| navigator.serviceWorker.addEventListener("controllerchange", () => { | ||
| location.reload(); | ||
| }, { once: true }); | ||
| }); | ||
| } else if (isServiceWorker) { | ||
| this.#fetchListener = async (event) => { | ||
| if (/\/[^/]*\.[a-zA-Z0-9]+$/.test(new URL(event.request.url).pathname)) return; | ||
| this.#fetchListener = (event) => { | ||
| Object.defineProperty(event.request, "waitUntil", { value: event.waitUntil.bind(event) }); | ||
| const response = await this.fetch(event.request, event); | ||
| if (response.status !== 404) event.respondWith(response); | ||
| event.respondWith((async () => { | ||
| const response = await this.fetch(event.request, event); | ||
| return response.status === 404 ? fetch(event.request) : response; | ||
| })()); | ||
| }; | ||
@@ -73,4 +76,4 @@ addEventListener("fetch", this.#fetchListener); | ||
| if (isBrowserWindow) { | ||
| const registrations = await navigator.serviceWorker.getRegistrations(); | ||
| for (const registration of registrations) if (registration.active) await registration.unregister(); | ||
| await this.#registration?.unregister(); | ||
| this.#registration = void 0; | ||
| } else if (isServiceWorker) await self.registration.unregister(); | ||
@@ -77,0 +80,0 @@ } |
+139
-103
@@ -22,6 +22,9 @@ import { bold, cyan, gray, green, magenta, red, url, yellow } from "./_chunks/_utils.mjs"; | ||
| const { serve: srvxServe } = loaded.nodeCompat ? await import("srvx/node") : await import("srvx"); | ||
| const { serveStatic } = await import("srvx/static"); | ||
| const { log } = await import("srvx/log"); | ||
| const { staticMiddleware } = await import("srvx/static"); | ||
| const { loggerMiddleware } = await import("srvx/log"); | ||
| const explicitStatic = !!cliOpts.static; | ||
| const staticDir = resolve(cliOpts.dir || (loaded.url ? dirname(fileURLToPath(loaded.url)) : "."), cliOpts.static || "public"); | ||
| cliOpts.static = existsSync(staticDir) ? staticDir : ""; | ||
| if (existsSync(staticDir)) cliOpts.static = staticDir; | ||
| else if (explicitStatic) throw new Error(`--static directory not found: ${staticDir}`); | ||
| else cliOpts.static = ""; | ||
| if (loaded.notFound && !cliOpts.static) { | ||
@@ -36,2 +39,10 @@ process.send?.({ error: "no-entry" }); | ||
| }; | ||
| let tls = serverOptions.tls; | ||
| if (cliOpts.tls) { | ||
| if (!cliOpts.cert || !cliOpts.key) throw new Error("--tls requires both --cert and --key."); | ||
| tls = { | ||
| cert: cliOpts.cert, | ||
| key: cliOpts.key | ||
| }; | ||
| } | ||
| printInfo(cliOpts, loaded); | ||
@@ -43,6 +54,3 @@ server = srvxServe({ | ||
| hostname: cliOpts.hostname ?? cliOpts.host ?? serverOptions.hostname, | ||
| tls: cliOpts.tls ? { | ||
| cert: cliOpts.cert, | ||
| key: cliOpts.key | ||
| } : void 0, | ||
| tls, | ||
| error: (error) => { | ||
@@ -54,4 +62,4 @@ console.error(error); | ||
| middleware: [ | ||
| log(), | ||
| cliOpts.static ? serveStatic({ dir: cliOpts.static }) : void 0, | ||
| loggerMiddleware(), | ||
| cliOpts.static ? staticMiddleware({ dir: cliOpts.static }) : void 0, | ||
| ...serverOptions.middleware || [] | ||
@@ -67,4 +75,5 @@ ].filter(Boolean) | ||
| function renderError(cliOpts, error, status = 500, title = "Server Error") { | ||
| let html = `<!DOCTYPE html><html><head><title>${title}</title></head><body>`; | ||
| if (cliOpts.prod) html += `<h1>${title}</h1><p>Something went wrong while processing your request.</p>`; | ||
| const safeTitle = escapeHtml(title); | ||
| let html = `<!DOCTYPE html><html><head><title>${safeTitle}</title></head><body>`; | ||
| if (cliOpts.prod) html += `<h1>${safeTitle}</h1><p>Something went wrong while processing your request.</p>`; | ||
| else html += ` | ||
@@ -78,3 +87,3 @@ <style> | ||
| </style> | ||
| <div id="error"><h1>${title}</h1><pre>${error instanceof Error ? error.stack || error.message : String(error)}</pre></div> | ||
| <div id="error"><h1>${safeTitle}</h1><pre>${escapeHtml(error instanceof Error ? error.stack || error.message : String(error))}</pre></div> | ||
| `; | ||
@@ -86,2 +95,12 @@ return new Response(html, { | ||
| } | ||
| const HTML_ESCAPES = { | ||
| "&": "&", | ||
| "<": "<", | ||
| ">": ">", | ||
| "\"": """, | ||
| "'": "'" | ||
| }; | ||
| function escapeHtml(str) { | ||
| return str.replace(/[&<>"']/g, (ch) => HTML_ESCAPES[ch]); | ||
| } | ||
| function printInfo(cliOpts, loaded) { | ||
@@ -126,3 +145,3 @@ let entryInfo; | ||
| stderr.write(`* Fetching remote URL: ${inputURL}\n`); | ||
| if (!URL?.canParse(inputURL)) inputURL = `http${cliOpts.tls ? "s" : ""}://${inputURL}`; | ||
| if (!/^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(inputURL)) inputURL = `http${cliOpts.tls ? "s" : ""}://${inputURL}`; | ||
| fetchHandler = globalThis.fetch; | ||
@@ -142,15 +161,22 @@ } | ||
| let body; | ||
| if (cliOpts.data !== void 0) if (cliOpts.data === "@-") body = new ReadableStream({ async start(controller) { | ||
| for await (const chunk of stdin) controller.enqueue(chunk); | ||
| controller.close(); | ||
| } }); | ||
| else if (cliOpts.data.startsWith("@")) body = Readable.toWeb(createReadStream(cliOpts.data.slice(1))); | ||
| else body = cliOpts.data; | ||
| let isStream = false; | ||
| if (cliOpts.data !== void 0) if (cliOpts.data === "@-") { | ||
| isStream = true; | ||
| body = new ReadableStream({ async start(controller) { | ||
| for await (const chunk of stdin) controller.enqueue(chunk); | ||
| controller.close(); | ||
| } }); | ||
| } else if (cliOpts.data.startsWith("@")) { | ||
| isStream = true; | ||
| body = Readable.toWeb(createReadStream(cliOpts.data.slice(1))); | ||
| } else body = cliOpts.data; | ||
| const method = cliOpts.method || (body === void 0 ? "GET" : "POST"); | ||
| const url = new URL(inputURL, `http${cliOpts.tls ? "s" : ""}://${cliOpts.host || cliOpts.hostname || "localhost"}`); | ||
| const req = new Request(url, { | ||
| const reqInit = { | ||
| method, | ||
| headers, | ||
| body | ||
| }); | ||
| }; | ||
| if (isStream) reqInit.duplex = "half"; | ||
| const req = new Request(url, reqInit); | ||
| if (cliOpts.verbose) { | ||
@@ -191,3 +217,3 @@ const parsedUrl = new URL(url); | ||
| name: "srvx", | ||
| version: "0.11.21", | ||
| version: "0.11.22", | ||
| description: "Universal Server." | ||
@@ -228,30 +254,34 @@ }; | ||
| ${green("--entry")} ${yellow("<file>")} Server entry file to use | ||
| ${green("--dir")} ${yellow("<dir>")} Working directory for resolving entry file | ||
| ${green("-h, --help")} Show this help message | ||
| ${green("--version")} Show server and runtime versions | ||
| ${green("--entry")} ${yellow("<file>")} Server entry file to use | ||
| ${green("--dir")} ${yellow("<dir>")} Working directory for resolving entry file | ||
| ${green("-h, --help")} Show this help message | ||
| ${green("--version")} Show server and runtime versions | ||
| ${bold("SERVE OPTIONS")} | ||
| ${green("-p, --port")} ${yellow("<port>")} Port to listen on (default: ${yellow("3000")}) | ||
| ${green("--host")} ${yellow("<host>")} Host to bind to (default: all interfaces) | ||
| ${green("-s, --static")} ${yellow("<dir>")} Serve static files from the specified directory (default: ${yellow("public")}) | ||
| ${green("--prod")} Run in production mode (no watch, no debug) | ||
| ${green("--import")} ${yellow("<loader>")} ES module to preload | ||
| ${green("--tls")} Enable TLS (HTTPS/HTTP2) | ||
| ${green("--cert")} ${yellow("<file>")} TLS certificate file | ||
| ${green("--key")} ${yellow("<file>")} TLS private key file | ||
| ${green("-p, --port")} ${yellow("<port>")} Port to listen on (default: ${yellow("3000")}) | ||
| ${green("--host, --hostname")} ${yellow("<host>")} Host to bind to (default: all interfaces) | ||
| ${green("-s, --static")} ${yellow("<dir>")} Serve static files from the specified directory (default: ${yellow("public")}) | ||
| ${green("--prod")} Run in production mode (no watch, no debug) | ||
| ${green("--import")} ${yellow("<loader>")} ES module to preload | ||
| ${green("--tls")} Enable TLS (HTTPS/HTTP2) | ||
| ${green("--cert")} ${yellow("<file>")} TLS certificate file | ||
| ${green("--key")} ${yellow("<file>")} TLS private key file | ||
| ${bold("FETCH OPTIONS")} | ||
| ${green("-X, --request")} ${yellow("<method>")} HTTP method (default: ${yellow("GET")}, or ${yellow("POST")} if body is provided) | ||
| ${green("-H, --header")} ${yellow("<header>")} Add header (format: "Name: Value", can be used multiple times) | ||
| ${green("-d, --data")} ${yellow("<data>")} Request body (use ${yellow("@-")} for stdin, ${yellow("@file")} for file) | ||
| ${green("-v, --verbose")} Show request and response headers | ||
| ${green("-X, --method")} ${yellow("<method>")} HTTP method (default: ${yellow("GET")}, or ${yellow("POST")} if body is provided; ${green("--request")} is a curl alias) | ||
| ${green("-H, --header")} ${yellow("<header>")} Add header (format: "Name: Value", can be used multiple times) | ||
| ${green("-d, --data")} ${yellow("<data>")} Request body (use ${yellow("@-")} for stdin, ${yellow("@file")} for file) | ||
| ${green("--host")} ${yellow("<host>")} Host for a schemeless URL/path (default: ${yellow("localhost")}) | ||
| ${green("--tls")} Use ${yellow("https")} for a schemeless URL/path | ||
| ${green("-v, --verbose")} Show request and response headers | ||
| Exits with code ${yellow("22")} on a non-2xx response (like ${cyan("curl --fail")}). | ||
| ${bold("ENVIRONMENT")} | ||
| ${green("PORT")} Override port | ||
| ${green("HOST")} Override host | ||
| ${green("NODE_ENV")} Set to ${yellow("production")} for production mode. | ||
| ${green("PORT")} Default port to listen on | ||
| ${green("HOST")} Default host to bind to | ||
| ${green("NODE_ENV")} Set to ${yellow("production")} for production mode. | ||
@@ -263,4 +293,12 @@ ${mainOpts.usage?.docs ? `➤ ${url("Documentation", mainOpts.usage.docs)}` : ""} | ||
| async function main(mainOpts) { | ||
| const args = process.argv.slice(2); | ||
| const cliOpts = parseArgs$1(args); | ||
| const args = mainOpts.args ?? process.argv.slice(2); | ||
| let cliOpts; | ||
| try { | ||
| cliOpts = parseArgs$1(args); | ||
| } catch (error) { | ||
| const command = mainOpts.usage?.command || "srvx"; | ||
| console.error(red(error.message || String(error))); | ||
| console.error(gray(`Run \`${command} --help\` for usage.`)); | ||
| process.exit(1); | ||
| } | ||
| if (cliOpts.version) { | ||
@@ -272,14 +310,17 @@ process.stdout.write(versions(mainOpts).join("\n") + "\n"); | ||
| console.log(usage(mainOpts)); | ||
| process.exit(cliOpts.help ? 0 : 1); | ||
| process.exit(0); | ||
| } | ||
| if (cliOpts.mode === "fetch") try { | ||
| const res = await cliFetch(cliOpts); | ||
| process.exit(res.ok ? 0 : 22); | ||
| } catch (error) { | ||
| console.error(error); | ||
| process.exit(1); | ||
| const envFiles = [".env", cliOpts.prod ? ".env.production" : ".env.local"].filter((f) => existsSync(f)); | ||
| if (cliOpts.mode === "fetch") { | ||
| for (const envFile of [...envFiles].reverse()) process.loadEnvFile?.(envFile); | ||
| try { | ||
| const res = await cliFetch(cliOpts); | ||
| process.exit(res.ok ? 0 : 22); | ||
| } catch (error) { | ||
| console.error(error); | ||
| process.exit(1); | ||
| } | ||
| } | ||
| if (process.send) return startServer(cliOpts); | ||
| console.log(gray([...versions(mainOpts), cliOpts.prod ? "prod" : "dev"].join(" · "))); | ||
| const envFiles = [".env", cliOpts.prod ? ".env.production" : ".env.local"].filter((f) => existsSync(f)); | ||
| if (envFiles.length > 0) console.log(`${gray(`Loading environment variables from ${magenta(envFiles.join(", "))}`)}`); | ||
@@ -301,46 +342,2 @@ if (cliOpts.prod && !cliOpts.import) { | ||
| function parseArgs$1(args) { | ||
| const pArg0 = args.find((a) => !a.startsWith("-")); | ||
| const mode = pArg0 === "fetch" || pArg0 === "curl" ? "fetch" : "serve"; | ||
| const commonArgs = { | ||
| help: { type: "boolean" }, | ||
| version: { type: "boolean" }, | ||
| dir: { type: "string" }, | ||
| entry: { type: "string" }, | ||
| host: { type: "string" }, | ||
| hostname: { type: "string" }, | ||
| tls: { type: "boolean" } | ||
| }; | ||
| if (mode === "serve") { | ||
| const { values, positionals } = parseArgs({ | ||
| args, | ||
| allowPositionals: true, | ||
| options: { | ||
| ...commonArgs, | ||
| url: { type: "string" }, | ||
| prod: { type: "boolean" }, | ||
| port: { | ||
| type: "string", | ||
| short: "p" | ||
| }, | ||
| static: { | ||
| type: "string", | ||
| short: "s" | ||
| }, | ||
| import: { type: "string" }, | ||
| cert: { type: "string" }, | ||
| key: { type: "string" } | ||
| } | ||
| }); | ||
| if (positionals[0] === "serve") positionals.shift(); | ||
| const maybeEntryOrDir = positionals[0]; | ||
| if (maybeEntryOrDir) { | ||
| if (values.entry || values.dir) throw new Error("Cannot specify entry or dir as positional argument when --entry or --dir is used!"); | ||
| if (statSync(maybeEntryOrDir).isDirectory()) values.dir = maybeEntryOrDir; | ||
| else values.entry = maybeEntryOrDir; | ||
| } | ||
| return { | ||
| mode, | ||
| ...values | ||
| }; | ||
| } | ||
| const { values, positionals } = parseArgs({ | ||
@@ -350,4 +347,25 @@ args, | ||
| options: { | ||
| ...commonArgs, | ||
| help: { | ||
| type: "boolean", | ||
| short: "h" | ||
| }, | ||
| version: { type: "boolean" }, | ||
| dir: { type: "string" }, | ||
| entry: { type: "string" }, | ||
| host: { type: "string" }, | ||
| hostname: { type: "string" }, | ||
| tls: { type: "boolean" }, | ||
| url: { type: "string" }, | ||
| prod: { type: "boolean" }, | ||
| port: { | ||
| type: "string", | ||
| short: "p" | ||
| }, | ||
| static: { | ||
| type: "string", | ||
| short: "s" | ||
| }, | ||
| import: { type: "string" }, | ||
| cert: { type: "string" }, | ||
| key: { type: "string" }, | ||
| method: { | ||
@@ -373,10 +391,28 @@ type: "string", | ||
| }); | ||
| if (positionals[0] === "fetch" || positionals[0] === "curl") positionals.shift(); | ||
| const method = values.method || values.request; | ||
| const url = values.url || positionals[0] || "/"; | ||
| let mode = "serve"; | ||
| const sub = positionals[0]; | ||
| if (sub === "fetch" || sub === "curl") { | ||
| mode = "fetch"; | ||
| positionals.shift(); | ||
| } else if (sub === "serve") positionals.shift(); | ||
| if (mode === "fetch") { | ||
| const method = values.method || values.request; | ||
| const url = values.url || positionals[0] || "/"; | ||
| return { | ||
| mode, | ||
| ...values, | ||
| url, | ||
| method | ||
| }; | ||
| } | ||
| const maybeEntryOrDir = positionals[0]; | ||
| if (maybeEntryOrDir) { | ||
| if (values.entry || values.dir) throw new Error("Cannot use a positional path together with --entry or --dir."); | ||
| if (!existsSync(maybeEntryOrDir)) throw new Error(`No such file or directory: ${maybeEntryOrDir}`); | ||
| if (statSync(maybeEntryOrDir).isDirectory()) values.dir = maybeEntryOrDir; | ||
| else values.entry = maybeEntryOrDir; | ||
| } | ||
| return { | ||
| mode, | ||
| ...values, | ||
| url, | ||
| method | ||
| ...values | ||
| }; | ||
@@ -419,3 +455,3 @@ } | ||
| process.on("SIGTERM", () => cleanup("SIGTERM", 143)); | ||
| if (args.includes("--watch")) process.on("SIGINT", () => cleanup("SIGINT", 130)); | ||
| if (runtimeArgs.includes("--watch")) process.on("SIGINT", () => cleanup("SIGINT", 130)); | ||
| } | ||
@@ -422,0 +458,0 @@ function setupProcessErrorHandlers() { |
+7
-4
@@ -1,2 +0,2 @@ | ||
| import { pathToFileURL } from "node:url"; | ||
| import { fileURLToPath, pathToFileURL } from "node:url"; | ||
| import { existsSync } from "node:fs"; | ||
@@ -20,6 +20,9 @@ import { resolve } from "node:path"; | ||
| let entry = opts.entry; | ||
| if (entry) { | ||
| if (entry) if (entry.startsWith("file://")) { | ||
| if (!existsSync(fileURLToPath(entry))) return { notFound: true }; | ||
| } else { | ||
| entry = resolve(opts.dir || ".", entry); | ||
| if (!existsSync(entry)) return { notFound: true }; | ||
| } else { | ||
| } | ||
| else { | ||
| for (const defEntry of defaultEntries) { | ||
@@ -52,3 +55,3 @@ for (const defExt of defaultExts) { | ||
| if (/"\.(m|c)?ts"/g.test(message)) throw new Error(`Make sure you're using Node.js v22.18+ or v24+ for TypeScript support (current version: ${process.versions.node})`, { cause: error }); | ||
| else if (/"\.(m|c)?tsx"/g.test(message)) throw new Error(`You need a compatible loader for JSX support (Deno, Bun or srvx --register jiti/register)`, { cause: error }); | ||
| else if (/"\.(m|c)?tsx"/g.test(message)) throw new Error(`You need a compatible loader for JSX support (Deno, Bun or srvx --import jiti/register)`, { cause: error }); | ||
| } | ||
@@ -55,0 +58,0 @@ throw error; |
+11
-3
| import { ServerMiddleware } from "./_chunks/types.mjs"; | ||
| interface LogOptions {} | ||
| declare const log: (options?: LogOptions) => ServerMiddleware; | ||
| export { LogOptions, log }; | ||
| interface LoggerMiddlewareOptions { | ||
| /** | ||
| * Batch lines and write once per event-loop turn (default: `true`). Set to | ||
| * `false` to hand each line to stdout in the request's own turn: slower | ||
| * under load, but a hard kill can only drop the write still in flight, | ||
| * never a batch waiting for its flush turn. | ||
| */ | ||
| batch?: boolean; | ||
| } | ||
| declare const loggerMiddleware: (options?: LoggerMiddlewareOptions) => ServerMiddleware; | ||
| export { LoggerMiddlewareOptions, loggerMiddleware }; |
+114
-9
| import { blue, bold, gray, green, red, yellow } from "./_chunks/_utils.mjs"; | ||
| const statusColors = { | ||
| 1: blue, | ||
| 2: green, | ||
| 3: yellow | ||
| }; | ||
| const log = (_options = {}) => { | ||
| const plain = (text) => text; | ||
| const paintForStatus = (code) => code < 200 ? blue : code < 300 ? green : code < 400 ? yellow : red; | ||
| function colorsEnabled() { | ||
| const env = globalThis.process?.env; | ||
| return !!env?.FORCE_COLOR || env?.NODE_ENV !== "production"; | ||
| } | ||
| const encoder = /* @__PURE__ */ new TextEncoder(); | ||
| const stdout = globalThis.process?.stdout; | ||
| const write = /* @__PURE__ */ (() => { | ||
| if (stdout?.write) return (chunk) => stdout.write(chunk); | ||
| return (chunk) => (console.log(chunk.slice(0, -1)), true); | ||
| })(); | ||
| const schedule = /* @__PURE__ */ (() => { | ||
| const setImmediate = globalThis.setImmediate; | ||
| return setImmediate ? (task) => void setImmediate(task) : (task) => queueMicrotask(task); | ||
| })(); | ||
| let pending = ""; | ||
| let scheduled = false; | ||
| let draining = false; | ||
| function enqueue(line) { | ||
| pending += line; | ||
| if (!scheduled && !draining) { | ||
| scheduled = true; | ||
| schedule(flush); | ||
| } | ||
| } | ||
| function writeNow(line) { | ||
| pending += line; | ||
| flush(); | ||
| } | ||
| function flush() { | ||
| scheduled = false; | ||
| if (draining || !pending) return; | ||
| const chunk = pending; | ||
| pending = ""; | ||
| try { | ||
| if (!write(chunk) && stdout?.once) { | ||
| draining = true; | ||
| stdout.once("drain", onDrain); | ||
| } | ||
| } catch {} | ||
| } | ||
| function onDrain() { | ||
| draining = false; | ||
| if (pending) { | ||
| scheduled = true; | ||
| schedule(flush); | ||
| } | ||
| } | ||
| function flushSync() { | ||
| if (!pending) return; | ||
| const proc = globalThis.process; | ||
| const chunk = pending; | ||
| pending = ""; | ||
| try { | ||
| const fs = proc?.getBuiltinModule?.("node:fs"); | ||
| if (fs) { | ||
| const bytes = encoder.encode(chunk); | ||
| for (let offset = 0; offset < bytes.length;) { | ||
| const written = fs.writeSync(1, bytes, offset, bytes.length - offset); | ||
| if (written <= 0) break; | ||
| offset += written; | ||
| } | ||
| } else proc?.stdout?.write(chunk); | ||
| } catch {} | ||
| } | ||
| let exitHooked = false; | ||
| function hookExit() { | ||
| const proc = globalThis.process; | ||
| if (exitHooked || !proc?.on) return; | ||
| exitHooked = true; | ||
| proc.on("exit", flushSync); | ||
| if (!proc.listenerCount || !proc.kill) return; | ||
| for (const [sig, signum] of [ | ||
| ["SIGHUP", 1], | ||
| ["SIGINT", 2], | ||
| ["SIGTERM", 15] | ||
| ]) { | ||
| const onSignal = () => { | ||
| if (proc.listenerCount(sig) > 1) return; | ||
| flushSync(); | ||
| proc.removeListener(sig, onSignal); | ||
| try { | ||
| proc.kill(proc.pid, sig); | ||
| } catch { | ||
| proc.exit(128 + signum); | ||
| } | ||
| }; | ||
| if (proc.prependListener) proc.prependListener(sig, onSignal); | ||
| else proc.on(sig, onSignal); | ||
| } | ||
| } | ||
| const loggerMiddleware = (options = {}) => { | ||
| const emit = options.batch === false ? writeNow : enqueue; | ||
| const colors = colorsEnabled(); | ||
| const paint = (fn) => colors ? fn : plain; | ||
| const gray$1 = paint(gray); | ||
| const bold$1 = paint(bold); | ||
| const blue$1 = paint(blue); | ||
| let cachedSecond = 0; | ||
| let cachedTime = ""; | ||
| const time = () => { | ||
| const now = Date.now(); | ||
| const second = now - now % 1e3; | ||
| if (second !== cachedSecond) { | ||
| cachedSecond = second; | ||
| cachedTime = gray$1(`[${new Date(now).toLocaleTimeString()}]`); | ||
| } | ||
| return cachedTime; | ||
| }; | ||
| const status = (code) => `[${paint(paintForStatus(code))(code + "")}]`; | ||
| hookExit(); | ||
| return async (req, next) => { | ||
@@ -12,7 +118,6 @@ const start = performance.now(); | ||
| const duration = performance.now() - start; | ||
| const statusColor = statusColors[Math.floor(res.status / 100)] || red; | ||
| console.log(`${gray(`[${(/* @__PURE__ */ new Date()).toLocaleTimeString()}]`)} ${bold(req.method)} ${blue(req.url)} [${statusColor(res.status + "")}] ${gray(`(${duration.toFixed(2)}ms)`)}`); | ||
| emit(`${time()} ${bold$1(req.method)} ${blue$1(req.url)} ${status(res.status)} ${gray$1(`(${duration.toFixed(2)}ms)`)}\n`); | ||
| return res; | ||
| }; | ||
| }; | ||
| export { log }; | ||
| export { loggerMiddleware }; |
+7
-7
@@ -39,3 +39,3 @@ import { ServerPlugin } from "./_chunks/types.mjs"; | ||
| * | ||
| * Populated by the {@link mtls} plugin. `undefined` when the request was not served over TLS. | ||
| * Populated by the {@link mtlsPlugin}. `undefined` when the request was not served over TLS. | ||
| */ | ||
@@ -51,5 +51,5 @@ tls?: ServerRequestTLS | undefined; | ||
| /** | ||
| * Options for the {@link mtls} plugin. | ||
| * Options for the {@link mtlsPlugin}. | ||
| */ | ||
| interface MTLSOptions { | ||
| interface MTLSPluginOptions { | ||
| /** | ||
@@ -101,7 +101,7 @@ * File path(s) or inlined CA certificate(s) in PEM format used to verify client certificates. | ||
| * import { serve } from "srvx/node"; | ||
| * import { mtls } from "srvx/mtls"; | ||
| * import { mtlsPlugin } from "srvx/mtls"; | ||
| * | ||
| * serve({ | ||
| * tls: { cert, key }, | ||
| * plugins: [mtls({ ca, requestCert: true, rejectUnauthorized: false })], | ||
| * plugins: [mtlsPlugin({ ca, requestCert: true, rejectUnauthorized: false })], | ||
| * fetch: (request) => { | ||
@@ -116,3 +116,3 @@ * if (!request.tls?.authorized) { | ||
| */ | ||
| declare function mtls(options?: MTLSOptions): ServerPlugin; | ||
| export { MTLSOptions, ServerRequestTLS, mtls }; | ||
| declare function mtlsPlugin(options?: MTLSPluginOptions): ServerPlugin; | ||
| export { MTLSPluginOptions, ServerRequestTLS, mtlsPlugin }; |
+9
-6
| import { resolveCertOrKey } from "./_chunks/_utils2.mjs"; | ||
| function mtls(options = {}) { | ||
| function mtlsPlugin(options = {}) { | ||
| return (server) => { | ||
| if (server.runtime !== "node") throw new Error(`[srvx] mtls() requires srvx's Node.js adapter (import { serve } from "srvx/node"). The "${server.runtime}" server cannot request or expose client certificates.`); | ||
| if ("Bun" in globalThis) throw new Error("[srvx] mtls() is not available on Bun: Bun does not expose the peer certificate to node:http(s) request handlers. See https://github.com/oven-sh/bun/issues/16254"); | ||
| if (server.options.protocol === "http" || !server.options.tls?.cert || !server.options.tls?.key) throw new Error("[srvx] mtls() requires an HTTPS server: set `tls.cert` and `tls.key`. Mutual TLS cannot run over plain HTTP."); | ||
| if (server.runtime !== "node") throw new Error(`[srvx] mtlsPlugin() requires srvx's Node.js adapter (import { serve } from "srvx/node"). The "${server.runtime}" server cannot request or expose client certificates.`); | ||
| if ("Bun" in globalThis) throw new Error("[srvx] mtlsPlugin() is not available on Bun: Bun does not expose the peer certificate to node:http(s) request handlers. See https://github.com/oven-sh/bun/issues/16254"); | ||
| const nodeOptions = server.options.node; | ||
| const cert = server.options.tls?.cert ?? nodeOptions?.cert; | ||
| const key = server.options.tls?.key ?? nodeOptions?.key; | ||
| if (server.options.protocol === "http" || !cert || !key) throw new Error("[srvx] mtlsPlugin() requires an HTTPS server: set `tls.cert` and `tls.key`. Mutual TLS cannot run over plain HTTP."); | ||
| let ca; | ||
| if (options.ca !== void 0) ca = (Array.isArray(options.ca) ? options.ca : [options.ca]).map((entry) => { | ||
| const resolved = resolveCertOrKey(entry); | ||
| if (!resolved) throw new TypeError("mtls() `ca` entries must be non-empty PEM strings or file paths."); | ||
| if (!resolved) throw new TypeError("mtlsPlugin() `ca` entries must be non-empty PEM strings or file paths."); | ||
| return resolved; | ||
@@ -32,2 +35,2 @@ }); | ||
| } | ||
| export { mtls }; | ||
| export { mtlsPlugin }; |
+85
-3
| import { ServerMiddleware } from "./_chunks/types.mjs"; | ||
| interface ServeStaticOptions { | ||
| interface StaticMiddlewareOptions { | ||
| /** | ||
@@ -12,2 +12,84 @@ * The directory to serve static files from. | ||
| /** | ||
| * Dot segments (a path segment starting with `.`, such as `.env` or `.git`) that may be served. | ||
| * | ||
| * An array allow-lists segments by exact name; a path containing any other dot segment falls | ||
| * through to `next()`. `true` serves every dot segment, `false` (or `[]`) none. | ||
| * | ||
| * @default [".well-known"] | ||
| */ | ||
| dotfiles?: boolean | string[]; | ||
| /** | ||
| * Serve precompressed variants from disk. Off by default: most deployments ship none, | ||
| * so probing for one is a `stat` that always misses, on every compressible request. | ||
| * | ||
| * `true` uses `{ br: ".br", gzip: ".gz" }`; a map sets the extension per encoding (keys | ||
| * tried in order, so list the preferred encoding first). For `/app.js` with | ||
| * `Accept-Encoding: br`, `app.js.br` is served if it exists. A variant always wins over | ||
| * on-the-fly `compress`, as it costs no CPU. `false` (the default) skips the lookup. | ||
| * | ||
| * @default false | ||
| */ | ||
| encodings?: boolean | Record<string, string>; | ||
| /** | ||
| * Compress a response on the fly when no precompressed variant is served. | ||
| * | ||
| * Applies to compressible types only, and only to files between 1 KiB and 10 MiB — | ||
| * precompress anything larger. Pass `false` to serve only what is already on disk (with | ||
| * `encodings` off too, nothing is ever compressed). | ||
| * | ||
| * @default true | ||
| */ | ||
| compress?: boolean; | ||
| /** | ||
| * Emit a `Last-Modified` header from the file's modification time, and answer an | ||
| * `If-Modified-Since` conditional request that still matches with `304 Not Modified`. | ||
| * | ||
| * @default true | ||
| */ | ||
| lastModified?: boolean; | ||
| /** | ||
| * Emit an `ETag` validator, and answer an `If-None-Match` conditional request that still | ||
| * matches with `304 Not Modified`. | ||
| * | ||
| * The tag is weak (`W/"…"`): it is derived from the file's size and modification time | ||
| * rather than its bytes, and folds in the `Content-Encoding`, so a brotli and a gzip | ||
| * response under one URL never share one — which a cache keying on `Vary` relies on. | ||
| * | ||
| * @default true | ||
| */ | ||
| etag?: boolean; | ||
| /** | ||
| * Freshness lifetime, in **seconds**, emitted as `Cache-Control: max-age=<n>`. | ||
| * | ||
| * Off by default: no `Cache-Control` header is sent, so a client revalidates | ||
| * with the `ETag`/`Last-Modified` validators on every use. Set it to let a | ||
| * client reuse a response without a request until it goes stale. | ||
| * | ||
| * @default undefined | ||
| */ | ||
| maxAge?: number; | ||
| /** | ||
| * Add the `immutable` directive to `Cache-Control`, telling a client not to | ||
| * revalidate a still-fresh response even on an explicit reload. | ||
| * | ||
| * Only takes effect alongside `maxAge`, and only makes sense for a | ||
| * fingerprinted (content-hashed) asset, whose URL changes when its bytes do. | ||
| * | ||
| * @default false | ||
| */ | ||
| immutable?: boolean; | ||
| /** | ||
| * Answer a single byte-range GET request with `206 Partial Content`, and | ||
| * advertise `Accept-Ranges: bytes` on responses that could serve one. | ||
| * | ||
| * A range request is served the identity bytes only — content negotiation is | ||
| * skipped — since a range over an on-the-fly (chunked) encoding is not | ||
| * expressible, and range consumers (media seek, download resumption) target | ||
| * already-compressed types. `false` disables it: no `206`/`416`, and the | ||
| * header is never sent. | ||
| * | ||
| * @default true | ||
| */ | ||
| ranges?: boolean; | ||
| /** | ||
| * A function to modify the HTML content before serving it. | ||
@@ -21,3 +103,3 @@ */ | ||
| } | ||
| declare const serveStatic: (options: ServeStaticOptions) => ServerMiddleware; | ||
| export { ServeStaticOptions, serveStatic }; | ||
| declare const staticMiddleware: (options: StaticMiddlewareOptions) => ServerMiddleware; | ||
| export { StaticMiddlewareOptions, staticMiddleware }; |
+289
-34
| import { FastURL } from "./_chunks/_url.mjs"; | ||
| import { createReadStream } from "node:fs"; | ||
| import { constants } from "node:fs"; | ||
| import { extname, join, resolve, sep } from "node:path"; | ||
| import { readFile, stat } from "node:fs/promises"; | ||
| import { pipeline } from "node:stream"; | ||
| import { open, realpath, stat } from "node:fs/promises"; | ||
| import { constants as constants$1, createBrotliCompress, createGzip } from "node:zlib"; | ||
| import { FastResponse } from "srvx"; | ||
| import { createBrotliCompress, createGzip } from "node:zlib"; | ||
| const COMMON_MIME_TYPES = { | ||
@@ -16,2 +17,3 @@ ".html": "text/html", | ||
| ".xml": "application/xml", | ||
| ".wasm": "application/wasm", | ||
| ".gif": "image/gif", | ||
@@ -24,49 +26,231 @@ ".ico": "image/vnd.microsoft.icon", | ||
| ".webp": "image/webp", | ||
| ".avif": "image/avif", | ||
| ".woff": "font/woff", | ||
| ".woff2": "font/woff2", | ||
| ".mp3": "audio/mpeg", | ||
| ".mp4": "video/mp4", | ||
| ".webm": "video/webm", | ||
| ".zip": "application/zip", | ||
| ".gz": "application/gzip", | ||
| ".pdf": "application/pdf" | ||
| }; | ||
| const serveStatic = (options) => { | ||
| const dir = resolve(options.dir) + sep; | ||
| const DEFAULT_DOTFILES = [".well-known"]; | ||
| const DEFAULT_ENCODINGS = { | ||
| br: ".br", | ||
| gzip: ".gz" | ||
| }; | ||
| const BROTLI_QUALITY = 4; | ||
| const COMPRESS_MIN_SIZE = 1024; | ||
| const COMPRESS_MAX_SIZE = 10 * 1024 * 1024; | ||
| const COMPRESSORS = { | ||
| br: (sizeHint) => createBrotliCompress({ params: { | ||
| [constants$1.BROTLI_PARAM_QUALITY]: BROTLI_QUALITY, | ||
| [constants$1.BROTLI_PARAM_SIZE_HINT]: sizeHint | ||
| } }), | ||
| gzip: () => createGzip() | ||
| }; | ||
| const asPrefix = (path) => path.endsWith(sep) ? path : path + sep; | ||
| const OPEN_FLAGS = constants.O_RDONLY | (constants.O_NONBLOCK ?? 0); | ||
| const staticMiddleware = (options) => { | ||
| const dir = asPrefix(resolve(options.dir)); | ||
| const methods = new Set((options.methods || ["GET", "HEAD"]).map((m) => m.toUpperCase())); | ||
| const dotfiles = options.dotfiles ?? DEFAULT_DOTFILES; | ||
| const allowAllDots = dotfiles === true; | ||
| const allowedDots = new Set(Array.isArray(dotfiles) ? dotfiles : []); | ||
| const isDeniedDotPath = (relPath) => !allowAllDots && relPath.split(sep).some((s) => s[0] === "." && !allowedDots.has(s)); | ||
| const encodings = options.encodings === true ? DEFAULT_ENCODINGS : options.encodings || {}; | ||
| const compress = options.compress ?? true; | ||
| const lastModified = options.lastModified ?? true; | ||
| const etag = options.etag ?? true; | ||
| const ranges = options.ranges ?? true; | ||
| const cacheControl = buildCacheControl(options.maxAge, options.immutable); | ||
| const served = [...Object.entries(encodings).map(([name, ext]) => ({ | ||
| name, | ||
| ext, | ||
| compressor: compress ? COMPRESSORS[name] : void 0 | ||
| })), ...compress ? Object.keys(COMPRESSORS).filter((name) => !(name in encodings)).map((name) => ({ | ||
| name, | ||
| compressor: COMPRESSORS[name] | ||
| })) : []]; | ||
| const varyOnEncoding = served.length > 0; | ||
| let realDir; | ||
| const getRealDir = async () => { | ||
| if (realDir === void 0) { | ||
| const resolved = await realpath(dir).catch(() => null); | ||
| if (resolved === null) return dir; | ||
| realDir = asPrefix(resolved); | ||
| } | ||
| return realDir; | ||
| }; | ||
| const statFile = async (candidate) => { | ||
| const fileStat = await stat(candidate).catch(() => null); | ||
| return fileStat?.isFile() ? fileStat : null; | ||
| }; | ||
| const openServable = async (candidate) => { | ||
| const handle = await open(candidate, OPEN_FLAGS).catch(() => null); | ||
| if (handle === null) return null; | ||
| try { | ||
| const fileStat = await handle.stat(); | ||
| const realPath = fileStat.isFile() ? await realpath(candidate).catch(() => null) : null; | ||
| if (realPath !== null) { | ||
| const root = await getRealDir(); | ||
| if (realPath.startsWith(root) && !isDeniedDotPath(realPath.slice(root.length))) { | ||
| const realStat = await stat(realPath).catch(() => null); | ||
| if (realStat && realStat.ino === fileStat.ino && realStat.dev === fileStat.dev) return { | ||
| handle, | ||
| size: fileStat.size, | ||
| mtimeMs: fileStat.mtimeMs | ||
| }; | ||
| } | ||
| } | ||
| } catch {} | ||
| await handle.close().catch(() => {}); | ||
| return null; | ||
| }; | ||
| return async (req, next) => { | ||
| if (!methods.has(req.method)) return next(); | ||
| const path = (req._url ??= new FastURL(req.url)).pathname.slice(1).replace(/\/$/, ""); | ||
| let path = (req._url ??= new FastURL(req.url)).pathname.slice(1); | ||
| const trailingSlash = path.endsWith("/"); | ||
| if (trailingSlash) path = path.replace(/\/+$/, ""); | ||
| if (path.includes("%")) try { | ||
| path = decodeURI(path); | ||
| } catch { | ||
| return new FastResponse("Bad Request", { status: 400 }); | ||
| } | ||
| let paths; | ||
| if (path === "") paths = ["index.html"]; | ||
| else if (extname(path) === "") paths = [`${path}.html`, `${path}/index.html`]; | ||
| else if (trailingSlash) paths = [`${path}/index.html`]; | ||
| else if (extname(path) === "") paths = [ | ||
| path, | ||
| `${path}.html`, | ||
| `${path}/index.html` | ||
| ]; | ||
| else paths = [path]; | ||
| for (const path of paths) { | ||
| const filePath = join(dir, path); | ||
| if (!filePath.startsWith(dir)) continue; | ||
| const fileStat = await stat(filePath).catch(() => null); | ||
| if (fileStat?.isFile()) { | ||
| const fileExt = extname(filePath); | ||
| const headers = { | ||
| "Content-Length": fileStat.size.toString(), | ||
| "Content-Type": COMMON_MIME_TYPES[fileExt] || "application/octet-stream" | ||
| }; | ||
| if (options.renderHTML && fileExt === ".html") return options.renderHTML({ | ||
| html: await readFile(filePath, "utf8"), | ||
| filename: filePath, | ||
| let acceptEncodings; | ||
| let rangeRequest; | ||
| for (const candidate of paths) { | ||
| const filePath = join(dir, candidate); | ||
| if (!filePath.startsWith(dir) || isDeniedDotPath(filePath.slice(dir.length))) continue; | ||
| if (!await statFile(filePath)) continue; | ||
| const contentType = COMMON_MIME_TYPES[extname(filePath)] || "application/octet-stream"; | ||
| const renderHTML = contentType === "text/html" ? options.renderHTML : void 0; | ||
| const compressible = !renderHTML && isCompressible(contentType); | ||
| if (rangeRequest === void 0) { | ||
| const header = ranges && req.method === "GET" ? req.headers.get("range") : null; | ||
| rangeRequest = header !== null && header.startsWith("bytes=") ? header : ""; | ||
| } | ||
| let encoding = ""; | ||
| let servePath = filePath; | ||
| let file = null; | ||
| if (compressible && !rangeRequest) { | ||
| acceptEncodings ??= parseAcceptEncoding(req.headers.get("accept-encoding"), served); | ||
| for (const spec of acceptEncodings) { | ||
| if (!spec.ext) continue; | ||
| const variantPath = filePath + spec.ext; | ||
| if (!await statFile(variantPath)) continue; | ||
| const variant = await openServable(variantPath); | ||
| if (variant) { | ||
| encoding = spec.name; | ||
| servePath = variantPath; | ||
| file = variant; | ||
| break; | ||
| } | ||
| } | ||
| } | ||
| file ??= await openServable(filePath); | ||
| if (!file) continue; | ||
| let compressor; | ||
| if (compressible && !rangeRequest && !encoding && file.size >= COMPRESS_MIN_SIZE && file.size <= COMPRESS_MAX_SIZE) { | ||
| const spec = acceptEncodings.find((s) => s.compressor); | ||
| if (spec) { | ||
| encoding = spec.name; | ||
| compressor = spec.compressor; | ||
| } | ||
| } | ||
| if (renderHTML) { | ||
| let html; | ||
| try { | ||
| html = await file.handle.readFile("utf8"); | ||
| } finally { | ||
| await file.handle.close().catch(() => {}); | ||
| } | ||
| const rendered = await renderHTML({ | ||
| html, | ||
| filename: servePath, | ||
| request: req | ||
| }); | ||
| let stream = createReadStream(filePath); | ||
| const acceptEncoding = req.headers.get("accept-encoding") || ""; | ||
| if (acceptEncoding.includes("br")) { | ||
| headers["Content-Encoding"] = "br"; | ||
| delete headers["Content-Length"]; | ||
| headers["Vary"] = "Accept-Encoding"; | ||
| stream = stream.pipe(createBrotliCompress()); | ||
| } else if (acceptEncoding.includes("gzip")) { | ||
| headers["Content-Encoding"] = "gzip"; | ||
| delete headers["Content-Length"]; | ||
| headers["Vary"] = "Accept-Encoding"; | ||
| stream = stream.pipe(createGzip()); | ||
| if (req.method !== "HEAD") return rendered; | ||
| await rendered.body?.cancel().catch(() => {}); | ||
| return new FastResponse(null, { | ||
| status: rendered.status, | ||
| statusText: rendered.statusText, | ||
| headers: rendered.headers | ||
| }); | ||
| } | ||
| const headers = { "Content-Type": contentType.startsWith("text/") ? `${contentType}; charset=utf-8` : contentType }; | ||
| if (!compressor) headers["Content-Length"] = file.size.toString(); | ||
| if (encoding) headers["Content-Encoding"] = encoding; | ||
| if (ranges && !encoding) headers["Accept-Ranges"] = "bytes"; | ||
| if (varyOnEncoding && compressible) headers["Vary"] = "Accept-Encoding"; | ||
| if (cacheControl) headers["Cache-Control"] = cacheControl; | ||
| let etagValue = ""; | ||
| if (etag) { | ||
| etagValue = computeETag(file.size, file.mtimeMs, encoding); | ||
| headers["ETag"] = etagValue; | ||
| } | ||
| const lastModifiedMs = Math.min(Math.floor(file.mtimeMs / 1e3) * 1e3, Math.floor(Date.now() / 1e3) * 1e3); | ||
| if (lastModified) headers["Last-Modified"] = new Date(lastModifiedMs).toUTCString(); | ||
| const conditionalGet = req.method === "GET" || req.method === "HEAD"; | ||
| let conditionalStatus = 0; | ||
| const ifNoneMatch = req.headers.get("if-none-match"); | ||
| if (ifNoneMatch !== null) { | ||
| if (matchesIfNoneMatch(ifNoneMatch, etagValue)) conditionalStatus = conditionalGet ? 304 : 412; | ||
| } else if (lastModified && conditionalGet) { | ||
| if (matchesIfModifiedSince(req.headers.get("if-modified-since"), lastModifiedMs)) conditionalStatus = 304; | ||
| } | ||
| if (conditionalStatus) { | ||
| await file.handle.close().catch(() => {}); | ||
| const conditionalHeaders = {}; | ||
| if (etagValue) conditionalHeaders["ETag"] = etagValue; | ||
| if (headers["Last-Modified"]) conditionalHeaders["Last-Modified"] = headers["Last-Modified"]; | ||
| if (headers["Vary"]) conditionalHeaders["Vary"] = headers["Vary"]; | ||
| if (cacheControl && conditionalStatus === 304) conditionalHeaders["Cache-Control"] = cacheControl; | ||
| return new FastResponse(null, { | ||
| status: conditionalStatus, | ||
| headers: conditionalHeaders | ||
| }); | ||
| } | ||
| if (rangeRequest) { | ||
| const ifRange = req.headers.get("if-range"); | ||
| if (ifRange === null || matchesIfRange(ifRange, lastModifiedMs)) { | ||
| const parsed = parseRange(rangeRequest, file.size); | ||
| if (parsed === "unsatisfiable") { | ||
| await file.handle.close().catch(() => {}); | ||
| return new FastResponse(null, { | ||
| status: 416, | ||
| headers: { "Content-Range": `bytes */${file.size}` } | ||
| }); | ||
| } | ||
| if (parsed) return new FastResponse(file.handle.createReadStream({ | ||
| start: parsed.start, | ||
| end: parsed.end | ||
| }), { | ||
| status: 206, | ||
| headers: { | ||
| ...headers, | ||
| "Content-Range": `bytes ${parsed.start}-${parsed.end}/${file.size}`, | ||
| "Content-Length": (parsed.end - parsed.start + 1).toString() | ||
| } | ||
| }); | ||
| } | ||
| return new FastResponse(stream, { headers }); | ||
| } | ||
| if (req.method === "HEAD") { | ||
| await file.handle.close().catch(() => {}); | ||
| return new FastResponse(null, { headers }); | ||
| } | ||
| const stream = file.handle.createReadStream(); | ||
| if (!compressor) return new FastResponse(stream, { headers }); | ||
| const encoded = compressor(file.size); | ||
| pipeline(stream, encoded, () => {}); | ||
| return new FastResponse(encoded, { headers }); | ||
| } | ||
@@ -76,2 +260,73 @@ return next(); | ||
| }; | ||
| export { serveStatic }; | ||
| function buildCacheControl(maxAge, immutable) { | ||
| if (maxAge === void 0) return ""; | ||
| const seconds = Number.isFinite(maxAge) ? Math.min(2147483648, Math.max(0, Math.floor(maxAge))) : 0; | ||
| return immutable ? `max-age=${seconds}, immutable` : `max-age=${seconds}`; | ||
| } | ||
| function isCompressible(mimeType) { | ||
| return mimeType.startsWith("text/") || mimeType.endsWith("+json") || mimeType.endsWith("+xml") || mimeType === "application/json" || mimeType === "application/xml" || mimeType === "application/wasm"; | ||
| } | ||
| function computeETag(size, mtimeMs, encoding) { | ||
| const tag = `${size.toString(16)}-${Math.trunc(mtimeMs).toString(16)}`; | ||
| return `W/"${encoding ? `${tag}-${encoding}` : tag}"`; | ||
| } | ||
| function matchesIfNoneMatch(header, etag) { | ||
| if (header.trim() === "*") return true; | ||
| if (!etag) return false; | ||
| const bare = etag.replace(/^W\//, ""); | ||
| return header.split(",").some((candidate) => candidate.trim().replace(/^W\//, "") === bare); | ||
| } | ||
| function matchesIfModifiedSince(header, lastModifiedMs) { | ||
| if (!header) return false; | ||
| const since = Date.parse(header); | ||
| return !Number.isNaN(since) && lastModifiedMs <= since; | ||
| } | ||
| function matchesIfRange(header, lastModifiedMs) { | ||
| const value = header.trim(); | ||
| if (value.startsWith("\"") || value.startsWith("W/")) return false; | ||
| return value === new Date(lastModifiedMs).toUTCString(); | ||
| } | ||
| function parseRange(header, size) { | ||
| const match = /^bytes=(\d*)-(\d*)$/.exec(header); | ||
| if (!match) return null; | ||
| const startStr = match[1]; | ||
| const endStr = match[2]; | ||
| if (startStr === "") { | ||
| if (endStr === "") return null; | ||
| const n = Number(endStr); | ||
| if (n === 0 || size === 0) return "unsatisfiable"; | ||
| return { | ||
| start: n >= size ? 0 : size - n, | ||
| end: size - 1 | ||
| }; | ||
| } | ||
| const start = Number(startStr); | ||
| let end = size - 1; | ||
| if (endStr !== "") { | ||
| const to = Number(endStr); | ||
| if (to < start) return null; | ||
| if (to < end) end = to; | ||
| } | ||
| return start >= size ? "unsatisfiable" : { | ||
| start, | ||
| end | ||
| }; | ||
| } | ||
| function parseAcceptEncoding(header, served) { | ||
| if (!header) return []; | ||
| const quality = /* @__PURE__ */ new Map(); | ||
| for (const part of header.split(",")) { | ||
| const [token, ...params] = part.split(";"); | ||
| const name = token.trim().toLowerCase(); | ||
| if (!name) continue; | ||
| let q = 1; | ||
| for (const param of params) { | ||
| const trimmed = param.trim(); | ||
| if (trimmed.startsWith("q=")) q = Number.parseFloat(trimmed.slice(2)) || 0; | ||
| } | ||
| quality.set(name, q); | ||
| } | ||
| const wildcard = quality.get("*"); | ||
| return served.filter(({ name }) => (quality.get(name) ?? wildcard ?? 0) > 0); | ||
| } | ||
| export { staticMiddleware }; |
+5
-3
| { | ||
| "name": "srvx", | ||
| "version": "0.11.22", | ||
| "version": "0.12.0", | ||
| "description": "Universal Server.", | ||
@@ -59,2 +59,4 @@ "homepage": "https://srvx.h3.dev", | ||
| "test:node-compat:bun": "bun vitest test/node.test.ts", | ||
| "test:node-adapters:deno": "deno run vitest test/node-adapters.test", | ||
| "test:node-adapters:bun": "bun vitest test/node-adapters.test.ts", | ||
| "typecheck": "tsc --noEmit --skipLibCheck", | ||
@@ -65,3 +67,3 @@ "vitest": "vitest" | ||
| "@cloudflare/workers-types": "^5", | ||
| "@hono/node-server": "^2.0.8", | ||
| "@hono/node-server": "^2.0.10", | ||
| "@mitata/counters": "^0.0.8", | ||
@@ -94,3 +96,3 @@ "@mjackson/node-fetch-server": "^0.7.0", | ||
| "typescript": "^7.0.2", | ||
| "undici": "~8.3.0", | ||
| "undici": "8.4.0", | ||
| "vitest": "^4.1.10" | ||
@@ -97,0 +99,0 @@ }, |
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 2 instances
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 2 instances
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
161223
20.31%3474
24.52%59
1.72%