h3
Version:
Minimal H(TTP) framework built for high performance and portability.
463 lines (462 loc) • 16.7 kB
JavaScript
import { NullProtoObj as EmptyObject } from "rou3";
import { FastResponse, FastURL } from "srvx";
function withoutTrailingSlash(path) {
if (!path || path === "/") return "/";
return path[path.length - 1] === "/" ? path.slice(0, -1) : path;
}
function withLeadingSlash(path) {
return !path ? "/" : path[0] === "/" ? path : "/" + path;
}
function joinURL(base, path) {
if (!path || path === "/") return base || "/";
if (!base) return path;
const segment = path.replace(JOIN_LEADING_SLASH_RE, "");
return (base[base.length - 1] === "/" ? base : base + "/") + segment;
}
const JOIN_LEADING_SLASH_RE = /^\//;
function stripBase(pathname, base) {
if (pathname === base || pathname.startsWith(base + "/") || pathname.startsWith(base + "?")) return "/" + pathname.slice(base.length).replace(/^\/+/, "");
return pathname;
}
function withoutBase(input = "", base = "") {
if (!base || base === "/") return input;
return stripBase(input, withoutTrailingSlash(base));
}
const AUTHORITY_RE = /^(?:[a-z][a-z\d+.-]*:)?\/\//i;
function getURLPathname(input) {
const path = AUTHORITY_RE.test(input) ? input.replace(AUTHORITY_RE, "").replace(/^[^/?#]*/, "") : input;
const end = path.search(/[#?]/);
return end === -1 ? path : path.slice(0, end);
}
const NEEDLESS_ESCAPE_SRC = String.raw`%(?:2[146-9A-E]|3[0-9ABD]|4[0-9A-F]|5[0-9ABDF]|6[1-9A-F]|7[0-9ACE])`;
const NEEDLESS_ESCAPE_RE = /* @__PURE__ */ new RegExp(NEEDLESS_ESCAPE_SRC, "i");
const NEEDLESS_ESCAPE_RE_G = /* @__PURE__ */ new RegExp(NEEDLESS_ESCAPE_SRC, "gi");
function isNonCanonicalPathname(pathname) {
return NEEDLESS_ESCAPE_RE.test(pathname);
}
function canonicalPathname(pathname) {
return pathname.replace(NEEDLESS_ESCAPE_RE_G, (m) => String.fromCharCode(Number.parseInt(m.slice(1), 16)));
}
const ABSOLUTE_URL_RE = /^[a-z][a-z\d+\-.]*:\/\//i;
const ROUTE_ENCODE_RE = /[\u0000-\u0020"#<>\u0060]|[^\u0000-\u007E]/gu;
function normalizeRoute(route) {
if (ABSOLUTE_URL_RE.test(route)) throw new Error(`Route patterns are pathnames, received URL: ${route}`);
if (route.charCodeAt(0) !== 47) route = `/${route}`;
route = canonicalPathname(route.replace(ROUTE_ENCODE_RE, encodeURIComponent));
return route.includes("/.") ? resolveDotSegments(route) : route;
}
function resolveDotSegments(pathname) {
const out = [];
let dot = false;
for (const segment of pathname.split("/")) {
dot = segment === "." || segment === "..";
if (!dot) out.push(segment);
else if (segment.length === 2 && out.length > 1) out.pop();
}
if (dot) out.push("");
return out.join("/");
}
function decodePathname(pathname) {
try {
return decodeURI(pathname);
} catch {
return;
}
}
const ENCODED_SEP_RE_G = /%(?:25)*(?:2f|5c)/gi;
const ENCODED_SEP_FLAT_RE_G = /%(?:2f|5c)/gi;
function decodePreservingSeparators(value, opts) {
if (!value.includes("%")) return value;
const decode = opts?.decode || decodeURIComponent;
const re = opts?.nested === false ? ENCODED_SEP_FLAT_RE_G : ENCODED_SEP_RE_G;
let result = "";
let lastIndex = 0;
re.lastIndex = 0;
for (let m; m = re.exec(value);) {
result += decode(value.slice(lastIndex, m.index)) + m[0];
lastIndex = m.index + m[0].length;
}
return result + decode(value.slice(lastIndex));
}
const kEventNS = "h3.internal.event.";
const kEventRes = /* @__PURE__ */ Symbol.for(`${kEventNS}res`);
const kEventResHeaders = /* @__PURE__ */ Symbol.for(`${kEventNS}res.headers`);
const kEventResErrHeaders = /* @__PURE__ */ Symbol.for(`${kEventNS}res.err.headers`);
const kMalformedURL = /* @__PURE__ */ Symbol.for(`${kEventNS}malformed`);
var H3Event = class {
app;
req;
url;
context;
static __is_event__ = true;
constructor(req, context, app) {
this.context = req.context = context || req.context || new EmptyObject();
this.req = req;
this.app = app;
const _url = req._url;
let url = _url && _url instanceof URL ? _url : new FastURL(req.url);
const pathname = url.pathname;
if (pathname.includes("%")) {
if (decodePathname(pathname) === void 0) this[kMalformedURL] = true;
else if (isNonCanonicalPathname(pathname)) url = new FastURL(`${url.protocol}//${url.host}${canonicalPathname(pathname)}${url.search}`);
}
this.url = url;
}
get res() {
return this[kEventRes] ||= new H3EventResponse();
}
get runtime() {
return this.req.runtime;
}
waitUntil(promise) {
this.req.waitUntil?.(promise);
}
toString() {
return `[${this.req.method}] ${this.req.url}`;
}
toJSON() {
return this.toString();
}
get node() {
return this.req.runtime?.node;
}
get headers() {
return this.req.headers;
}
get path() {
return this.url.pathname + this.url.search;
}
get method() {
return this.req.method;
}
};
var H3EventResponse = class {
status;
statusText;
get headers() {
return this[kEventResHeaders] ||= new Headers();
}
get errHeaders() {
return this[kEventResErrHeaders] ||= new Headers();
}
};
const DISALLOWED_STATUS_CHARS = /[^\u0009\u0020-\u007E]/g;
function sanitizeStatusMessage(statusMessage = "") {
return statusMessage.replace(DISALLOWED_STATUS_CHARS, "");
}
function sanitizeStatusCode(statusCode, defaultStatusCode = 200) {
if (!statusCode) return defaultStatusCode;
if (typeof statusCode === "string") statusCode = +statusCode;
if (!Number.isInteger(statusCode) || statusCode < 100 || statusCode > 599) return defaultStatusCode;
return statusCode;
}
var HTTPError = class HTTPError extends Error {
get name() {
return "HTTPError";
}
status;
statusText;
headers;
cause;
data;
body;
unhandled;
static isError(input) {
return input instanceof Error && input?.name === "HTTPError" && input.status > 99;
}
static status(status, statusText, details) {
return new HTTPError({
...details,
statusText,
status
});
}
constructor(arg1, arg2) {
let messageInput;
let details;
if (typeof arg1 === "string") {
messageInput = arg1;
details = arg2;
} else details = arg1;
const status = sanitizeStatusCode(details?.status || details?.statusCode || (details?.cause)?.status || (details?.cause)?.statusCode, 500);
const statusText = sanitizeStatusMessage(details?.statusText || details?.statusMessage || (details?.cause)?.statusText || (details?.cause)?.statusMessage);
const message = messageInput || details?.message || (details?.cause)?.message || details?.statusText || details?.statusMessage || [
"HTTPError",
status,
statusText
].filter(Boolean).join(" ");
super(message, { cause: details });
this.cause = details;
this.status = status;
this.statusText = statusText || void 0;
const rawHeaders = details?.headers || (details?.cause)?.headers;
this.headers = rawHeaders ? new Headers(rawHeaders) : void 0;
this.unhandled = details?.unhandled ?? (details?.cause)?.unhandled ?? void 0;
this.data = details?.data;
this.body = details?.body;
}
get statusCode() {
return this.status;
}
get statusMessage() {
return this.statusText;
}
toJSON() {
const unhandled = this.unhandled;
return {
status: this.status,
statusText: this.statusText,
unhandled,
message: unhandled ? "HTTPError" : this.message,
data: unhandled ? void 0 : this.data,
...unhandled ? void 0 : this.body
};
}
};
function hasProp(obj, prop) {
try {
return prop in obj;
} catch {
return false;
}
}
function isJSONSerializable(value, _type) {
if (value === null || value === void 0) return true;
if (_type !== "object") return _type === "boolean" || _type === "number" || _type === "string";
if (typeof value.toJSON === "function") return true;
if (Array.isArray(value)) return true;
if (typeof value.pipe === "function" || typeof value.pipeTo === "function") return false;
if (value instanceof EmptyObject) return true;
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
}
const kEventDispose = /* @__PURE__ */ Symbol.for("h3.internal.event.dispose");
function onDispose(event, cb) {
let state = event[kEventDispose];
if (!state) {
const _state = {
callbacks: [],
observe: (response, val) => observeResponse(response, val, event, _state)
};
state = event[kEventDispose] = _state;
}
if (state.disposed) invokeDisposeCallbacks(event, [cb], state.reason);
else state.callbacks.push(cb);
}
function observeResponse(response, val, event, state) {
if (state.observing || state.disposed) return response;
state.observing = true;
const nodeRes = event.runtime?.node?.res;
if (nodeRes) {
if (nodeRes.closed || nodeRes.destroyed) {
fireDispose(event, state, nodeRes.errored ?? abortError());
return response;
}
nodeRes.once("close", () => {
fireDispose(event, state, nodeRes.errored ?? (nodeRes.writableFinished ? void 0 : abortError()));
});
return response;
}
if (!isStreamBody(val) || !response.body) {
fireDispose(event, state, void 0);
return response;
}
const body = response.body;
const { readable, writable } = new TransformStream();
body.pipeTo(writable).then(() => fireDispose(event, state, void 0), (reason) => fireDispose(event, state, reason === void 0 ? abortError() : reason));
return new FastResponse(readable, {
status: response.status,
statusText: response.statusText,
headers: response.headers
});
}
function fireDispose(event, state, reason) {
if (state.disposed) return;
state.disposed = true;
state.reason = reason;
const callbacks = state.callbacks;
state.callbacks = [];
invokeDisposeCallbacks(event, callbacks, reason);
}
function invokeDisposeCallbacks(event, callbacks, reason) {
const pending = [];
for (const cb of callbacks) try {
const res = cb(reason);
if (typeof res?.then === "function") pending.push(Promise.resolve(res).catch((error) => reportDisposeError(event, error)));
} catch (error) {
reportDisposeError(event, error);
}
if (pending.length > 0) event.waitUntil(Promise.all(pending));
}
function isStreamBody(val) {
return val instanceof ReadableStream || val instanceof Blob || val instanceof Response || val?.body instanceof ReadableStream;
}
function abortError() {
return new DOMException("Connection closed prematurely.", "AbortError");
}
function reportDisposeError(event, error) {
if (!event.app?.config.silent) console.error("[h3] onDispose:", error);
}
const kNotFound = /* @__PURE__ */ Symbol.for("h3.notFound");
const kHandled = /* @__PURE__ */ Symbol.for("h3.handled");
function toResponse(val, event, config = {}) {
if (typeof val?.then === "function") return val.then((resolvedVal) => toResponse(resolvedVal, event, config), (r) => toResponse(toError(r), event, config));
let response;
try {
response = prepareResponse(val, event, config);
} catch (error) {
return toResponse(toError(error), event, config);
}
if (typeof response?.then === "function") return toResponse(response, event, config);
const { onResponse } = config;
if (onResponse) return Promise.resolve().then(() => onResponse(response, event)).catch((error) => {
if (!config.silent) console.error(error);
}).then(() => event[kEventDispose]?.observe(response, val) ?? response);
return event[kEventDispose]?.observe(response, val) ?? response;
}
function toError(value) {
if (value === kNotFound || value === kHandled || value instanceof Error) return value;
if (typeof value === "number") return new HTTPError({ status: value });
const error = new HTTPError({
status: 500,
unhandled: true
});
error.cause = value;
return error;
}
const kHTTPResponse = /* @__PURE__ */ Symbol.for("h3.HTTPResponse");
var HTTPResponse = class {
#headers;
#init;
body;
constructor(body, init) {
this.body = body;
this.#init = init;
}
get status() {
return this.#init?.status;
}
get statusText() {
return this.#init?.statusText;
}
get headers() {
return this.#headers ||= new Headers(this.#init?.headers);
}
};
HTTPResponse.prototype[kHTTPResponse] = true;
function prepareResponse(val, event, config, nested) {
if (val === kHandled) return new FastResponse(null);
if (val === kNotFound) val = new HTTPError({
status: 404,
message: `Cannot find any route matching [${event.req.method}] ${event.url}`
});
if (val && val instanceof Error) {
const isHTTPError = HTTPError.isError(val);
const error = isHTTPError ? val : new HTTPError(val);
if (!isHTTPError) {
error.unhandled = true;
if (val?.stack) error.stack = val.stack;
}
if (error.unhandled && !config.silent) console.error(error);
const { onError } = config;
const errHeaders = event[kEventRes]?.[kEventResErrHeaders];
if (onError && !nested) return Promise.resolve().then(() => onError(error, event)).catch(toError).then((newVal) => prepareResponse(newVal ?? val, event, config, true));
event[kEventRes] = void 0;
return errorResponse(error, config.debug, errHeaders);
}
const preparedRes = event[kEventRes];
let preparedHeaders = preparedRes?.[kEventResHeaders];
event[kEventRes] = void 0;
if (!(val instanceof Response)) {
const res = prepareResponseBody(val, event, config);
const rawStatus = res.status || preparedRes?.status;
const status = rawStatus ? sanitizeStatusCode(rawStatus) : void 0;
const rawStatusText = res.statusText || preparedRes?.statusText;
return new FastResponse(nullBody(event.req.method, status) ? null : res.body, {
status,
statusText: rawStatusText === void 0 ? void 0 : sanitizeStatusMessage(rawStatusText),
headers: res.headers && preparedHeaders ? mergeHeaders(res.headers, preparedHeaders) : res.headers || preparedHeaders
});
}
if (val.status >= 400) preparedHeaders = preparedRes?.[kEventResErrHeaders];
if (preparedHeaders && !nested && !preparedHeaders.keys().next().done) return new FastResponse(nullBody(event.req.method, val.status) ? null : val.body, {
status: val.status,
statusText: val.statusText,
headers: mergeHeaders(val.headers, preparedHeaders)
});
return event.req.method === "HEAD" && val.body !== null ? new FastResponse(null, {
status: val.status,
statusText: val.statusText,
headers: val.headers
}) : val;
}
function mergeHeaders(base, overrides, target = new Headers(base)) {
for (const [name, value] of overrides) if (name === "set-cookie") target.append(name, value);
else target.set(name, value);
return target;
}
const frozen = (name) => (...args) => {
throw new Error(`Headers are frozen (${name} ${args.join(", ")})`);
};
var FrozenHeaders = class extends Headers {
set = frozen("set");
append = frozen("append");
delete = frozen("delete");
};
const emptyHeaders = /* @__PURE__ */ new FrozenHeaders({ "content-length": "0" });
const jsonHeaders = /* @__PURE__ */ new FrozenHeaders({ "content-type": "application/json;charset=UTF-8" });
function prepareResponseBody(val, event, config) {
if (val === null || val === void 0) return {
body: "",
headers: emptyHeaders
};
const valType = typeof val;
if (valType === "string") return { body: val };
if (val instanceof Uint8Array) return {
body: val,
headers: new Headers({ "content-length": val.byteLength.toString() })
};
if (val instanceof HTTPResponse || val?.[kHTTPResponse] === true) return val;
if (isJSONSerializable(val, valType)) return {
body: JSON.stringify(val, void 0, config.debug ? 2 : void 0),
headers: jsonHeaders
};
if (valType === "bigint") return {
body: val.toString(),
headers: jsonHeaders
};
if (val instanceof Blob) {
const headers = new Headers({
"content-type": val.type,
"content-length": val.size.toString()
});
let filename = val.name;
if (filename) {
filename = encodeURIComponent(filename);
headers.set("content-disposition", `filename="${filename}"; filename*=UTF-8''${filename}`);
}
return {
body: val.stream(),
headers
};
}
if (valType === "symbol") return { body: val.toString() };
if (valType === "function") return { body: `${val.name}()` };
return { body: val };
}
function nullBody(method, status) {
return method === "HEAD" || status === 100 || status === 101 || status === 102 || status === 204 || status === 205 || status === 304;
}
function errorResponse(error, debug, errHeaders) {
let headers = error.headers ? mergeHeaders(jsonHeaders, error.headers) : new Headers(jsonHeaders);
if (errHeaders) headers = mergeHeaders(headers, errHeaders);
return new FastResponse(JSON.stringify({
...error.toJSON(),
stack: debug && error.stack ? error.stack.split("\n").map((l) => l.trim()) : void 0
}, void 0, debug ? 2 : void 0), {
status: error.status,
statusText: error.statusText,
headers
});
}
export { EmptyObject, H3Event, HTTPError, HTTPResponse, canonicalPathname, decodePreservingSeparators, getURLPathname, hasProp, joinURL, kHandled, kMalformedURL, kNotFound, normalizeRoute, onDispose, sanitizeStatusCode, sanitizeStatusMessage, stripBase, toError, toResponse, withLeadingSlash, withoutBase, withoutTrailingSlash };