Sign In

srvx

Package Overview
Dependencies
Maintainers
1
Versions
84
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

srvx - npm Package Compare versions

Comparing version
0.11.22
to
0.12.0
+8
-7
dist/_chunks/_plugins.mjs
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;

@@ -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 @@ }

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 = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
"\"": "&quot;",
"'": "&#39;"
};
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() {

@@ -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;

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 };
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 };

@@ -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 };
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 };
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 };
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 };
{
"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 @@ },