h3
Version:
Minimal H(TTP) framework built for high performance and portability.
599 lines (598 loc) • 22.6 kB
JavaScript
import { canonicalPathname, withLeadingSlash } from "./response.mjs";
import { handleCors, redirect as redirect$1 } from "./cors.mjs";
import { canonicalPath, decodedPath, isHostPositionSplat, mergedCanonicalPath, needsCanonicalPasses, prepareRuleTarget } from "./_utils.mjs";
import { addRoute, compareRoutes, createRouter, findAllRoutes, routeNodeKeys } from "rou3";
const HTTP_METHODS = /* @__PURE__ */ new Set([
"GET",
"HEAD",
"POST",
"PUT",
"PATCH",
"DELETE",
"OPTIONS",
"CONNECT",
"TRACE",
"QUERY"
]);
const METHOD_KEY_RE = /^([A-Za-z]+)\s+(\/.*)$/;
function parseRouteKey(key) {
const match = METHOD_KEY_RE.exec(key);
if (match) {
const method = match[1].toUpperCase();
if (HTTP_METHODS.has(method)) return {
method,
path: withLeadingSlash(match[2])
};
}
return {
method: "",
path: withLeadingSlash(key)
};
}
function unknownMethodPrefix(key) {
const match = METHOD_KEY_RE.exec(key);
return match && !HTTP_METHODS.has(match[1].toUpperCase()) ? match[1] : void 0;
}
function formatRouteKey(method, path) {
return method ? `${method} ${path}` : path;
}
const ESCAPE_RUN_RE = /(?:%[\da-f]{2})+/gi;
const UNSAFE_DECODED_RE = /[/\\:*()%]/;
function decodeRoutePattern(path) {
if (!path.includes("%")) return path;
path = canonicalPathname(path);
return path.replace(ESCAPE_RUN_RE, (run) => {
let decoded;
try {
decoded = decodeURIComponent(run);
} catch {
return run;
}
return UNSAFE_DECODED_RE.test(decoded) ? run : decoded;
});
}
function mergeMatchedRouteRules(rawLayers, altLayers, canOverride) {
const resets = /* @__PURE__ */ new Set();
const routeRules = resolveLayers(rawLayers, resets);
for (const layers of altLayers || []) unionLayers(routeRules, layers, canOverride, resets);
return routeRules;
}
function unionLayers(routeRules, layers, canOverride, resets) {
if (!layers?.length) return;
const resolved = resolveLayers(layers, resets);
for (const [name, rule] of Object.entries(resolved)) {
const current = routeRules[name];
if (current) {
if (canOverride && !canOverride(current.route, rule.route)) continue;
} else if (resets?.has(name) && !rule.handler?.restricting) continue;
mergeRouteRule(routeRules, name, rule, rule.params);
}
}
function resolveLayers(layers, resets) {
const firstData = layers?.[0]?.data;
if (firstData && !Array.isArray(firstData)) return resolvePreMergedLayers(layers, resets);
const routeRules = emptyRouteRules();
for (const layer of orderedLayers(layers)) for (const entry of layer.data) {
if (entry.options === false) resets?.add(entry.name);
mergeRouteRule(routeRules, entry.name, entry, layer.params);
}
return routeRules;
}
function isMergeableObject(value) {
return value !== null && typeof value === "object";
}
function emptyRouteRules() {
return Object.create(null);
}
function mergeRuleOptions(current, incoming) {
return isMergeableObject(current) && isMergeableObject(incoming) ? {
...current,
...incoming
} : incoming;
}
function orderedLayers(layers) {
if (!layers || layers.length < 2) return layers || [];
let ordered = layers;
for (let i = 1; i < ordered.length; i++) {
const layer = ordered[i];
const rank = layerRank(layer);
let j = i - 1;
while (j >= 0 && layerRank(ordered[j]) > rank) {
if (ordered === layers) ordered = [...layers];
ordered[j + 1] = ordered[j];
j--;
}
if (j + 1 !== i) ordered[j + 1] = layer;
}
return ordered;
}
function layerRank(layer) {
return layer.data[0]?.rank ?? 0;
}
function resolvePreMergedLayers(rawLayers, resets) {
const layers = rawLayers.length < 2 ? rawLayers : [...rawLayers].sort((a, b) => a.data.rank - b.data.rank);
const routeRules = emptyRouteRules();
const winning = layers[layers.length - 1].data;
if (resets && winning.resets) for (const name of winning.resets) resets.add(name);
for (const entry of winning.rules) {
const paramRoutes = entry.paramRoutes;
let params;
for (const layer of layers) {
const layerParams = layer.params;
if (!layerParams) continue;
const layerRoute = layer.data.route;
if (paramRoutes ? paramRoutes.includes(layerRoute) : layerRoute === entry.route) params = params ? {
...params,
...layerParams
} : layerParams;
}
routeRules[entry.name] = {
route: entry.route,
options: entry.options,
handler: entry.handler,
params
};
}
return routeRules;
}
function mergeRouteRule(routeRules, ruleName, rule, params) {
const name = ruleName;
const currentRule = routeRules[name];
if (currentRule) {
if (rule.options === false) {
delete routeRules[name];
return;
}
currentRule.options = mergeRuleOptions(currentRule.options, rule.options);
currentRule.route = rule.route;
if (currentRule.params || params) currentRule.params = {
...currentRule.params,
...params
};
} else if (rule.options !== false) routeRules[name] = {
route: rule.route,
options: rule.options,
handler: rule.handler,
params
};
}
function sharedNodeMethods(byPath) {
const parents = /* @__PURE__ */ new Map();
const find = (key) => {
let root = key;
while (parents.get(root) !== root) root = parents.get(root);
return root;
};
const keysByPath = /* @__PURE__ */ new Map();
for (const path of byPath.keys()) {
const keys = nodeKeys(path);
keysByPath.set(path, keys);
for (const key of keys) if (!parents.has(key)) parents.set(key, key);
for (const key of keys) {
const [a, b] = [find(keys[0]), find(key)];
if (a !== b) parents.set(b, a);
}
}
const groupMethods = /* @__PURE__ */ new Map();
for (const [path, methods] of byPath) for (const method of methods.keys()) {
if (!method) continue;
const root = find(keysByPath.get(path)[0]);
let scoped = groupMethods.get(root);
if (!scoped) groupMethods.set(root, scoped = /* @__PURE__ */ new Set());
scoped.add(method);
}
if (groupMethods.size === 0) return /* @__PURE__ */ new Map();
const byPathMethods = /* @__PURE__ */ new Map();
for (const [path, keys] of keysByPath) {
const scoped = groupMethods.get(find(keys[0]));
if (scoped?.size) byPathMethods.set(path, scoped);
}
return byPathMethods;
}
function nodeKeys(path) {
let keys;
try {
keys = routeNodeKeys(path);
} catch (error) {
throw new Error(`[h3] rules: invalid route pattern \`${path}\`: ${error.message}`, { cause: error });
}
return keys.length > 0 ? keys : [path];
}
function routeContainmentRanks(paths) {
const ranks = new Map(paths.map((path) => [path, 0]));
for (let i = 0; i < paths.length; i++) for (let j = i + 1; j < paths.length; j++) {
const [a, b] = [paths[i], paths[j]];
let rel;
try {
rel = compareRoutes(a, b);
} catch {
continue;
}
if (rel === "superset") ranks.set(b, ranks.get(b) + 1);
else if (rel === "subset") ranks.set(a, ranks.get(a) + 1);
}
return ranks;
}
function preMergeRuleLayers(byPath) {
const paths = [...byPath.keys()];
const subsumers = new Map(paths.map((path) => [path, []]));
for (let i = 0; i < paths.length; i++) for (let j = i + 1; j < paths.length; j++) {
const [a, b] = [paths[i], paths[j]];
switch (compareRoutes(a, b)) {
case "disjoint": break;
case "equal": throw new Error(`[h3] rules: preMerge: \`${a}\` and \`${b}\` match the same paths — merge them into one rule`);
case "superset":
subsumers.get(b).push(a);
break;
case "subset":
subsumers.get(a).push(b);
break;
case "partial": throw new Error(`[h3] rules: preMerge: \`${a}\` and \`${b}\` partially overlap — the most specific match is ambiguous. Split the overlap into explicit rules or disable preMerge.`);
}
}
const methodsUsed = /* @__PURE__ */ new Set();
for (const methods of byPath.values()) for (const method of methods.keys()) if (method) methodsUsed.add(method);
const result = /* @__PURE__ */ new Map();
for (const path of paths) {
const chainSubsumers = subsumers.get(path);
const chain = [...chainSubsumers].sort((a, b) => subsumers.get(a).length - subsumers.get(b).length).concat(path);
const registrations = /* @__PURE__ */ new Map();
for (const method of ["", ...methodsUsed]) {
if (method && !chain.some((route) => byPath.get(route).has(method))) continue;
const merged = /* @__PURE__ */ new Map();
const resets = /* @__PURE__ */ new Set();
for (const route of chain) {
const methods = byPath.get(route);
const agnostic = methods.get("") || [];
const scoped = method && methods.get(method) || [];
for (const entry of [...agnostic, ...scoped]) mergeChainRule(merged, entry, resets);
}
registrations.set(method, {
route: path,
rank: chainSubsumers.length,
rules: [...merged.values()].map(({ paramRoutes, ...rule }) => paramRoutes.length === 1 && paramRoutes[0] === rule.route ? rule : {
...rule,
paramRoutes
}),
...resets.size > 0 && { resets: [...resets].filter((name) => !merged.has(name)) }
});
}
result.set(path, registrations);
}
return result;
}
function mergeChainRule(merged, entry, resets) {
if (entry.options === false) resets.add(entry.name);
const current = merged.get(entry.name);
if (current) {
if (entry.options === false) {
merged.delete(entry.name);
return;
}
current.options = mergeRuleOptions(current.options, entry.options);
current.route = entry.route;
if (!current.paramRoutes.includes(entry.route)) current.paramRoutes.push(entry.route);
} else if (entry.options !== false) merged.set(entry.name, {
...entry,
paramRoutes: [entry.route]
});
}
let warnedCredentialsWildcard = false;
function safeCorsOptions(options) {
const { origin, credentials } = options;
if (credentials === true && (!origin || origin === "*")) {
if (!warnedCredentialsWildcard) {
warnedCredentialsWildcard = true;
console.warn("[h3] rules: `cors` rule resolved to `credentials: true` with a wildcard origin after merge — dropping `credentials` (an `Access-Control-Allow-Origin: *` + credentials response is rejected by browsers). Set an explicit `origin` allowlist on the more specific rule.");
}
return {
...options,
credentials: false
};
}
return options;
}
const cors = {
order: -3,
handler: (m) => function corsRouteRule(event, next) {
const preflight = handleCors(event, safeCorsOptions(m.options || {}));
return preflight === false ? next() : preflight;
}
};
const headers = {
order: -1,
handler: (m) => {
const entries = Object.entries(m.options || {});
return async function headersRouteRule(event, next) {
try {
return await next();
} finally {
for (const [key, value] of entries) {
event.res.headers.set(key, value);
event.res.errHeaders.set(key, value);
}
}
};
}
};
const redirect = {
order: 1,
handler: (m) => {
const options = m.options;
const resolveTarget = prepareRuleTarget(options);
if (!resolveTarget) return function redirectRouteRule() {};
return function redirectRouteRule(event) {
return redirect$1(resolveTarget(event), options?.status);
};
}
};
const ruleHandlers = {
headers,
redirect,
cors
};
function createRulesRouter(rules, handlers, baseURL, preMerge) {
let base = baseURL || "";
if (base.endsWith("/")) base = base.slice(0, -1);
const byPath = /* @__PURE__ */ new Map();
for (const [key, rule] of Object.entries(rules)) {
const { method, path } = parseRouteKey(key);
const entries = [];
for (const [name, options] of Object.entries(rule)) {
if (options === void 0) continue;
entries.push({
name,
route: path,
options: base ? withScopeBase(name, options, base) : options,
handler: Object.hasOwn(handlers, name) ? handlers[name] : void 0
});
}
let methods = byPath.get(path);
if (!methods) byPath.set(path, methods = /* @__PURE__ */ new Map());
methods.set(method, [...methods.get(method) || [], ...entries]);
}
for (const methods of byPath.values()) {
const get = methods.get("GET");
if (get) methods.set("HEAD", [...get, ...methods.get("HEAD") || []]);
}
const router = createRouter();
if (preMerge) {
for (const [path, methods] of preMergeRuleLayers(byPath)) for (const [method, data] of methods) addRoute(router, method, base + path, data);
return router;
}
for (const [path, rank] of routeContainmentRanks([...byPath.keys()])) {
if (rank === 0) continue;
for (const entries of byPath.get(path).values()) for (const entry of entries) entry.rank = rank;
}
const sharedMethods = sharedNodeMethods(byPath);
for (const [path, methods] of byPath) {
const agnostic = methods.get("");
if (!agnostic) continue;
addRoute(router, "", base + path, agnostic);
for (const method of sharedMethods.get(path) || []) addRoute(router, method, base + path, agnostic);
}
for (const [path, methods] of byPath) for (const [method, entries] of methods) if (method) addRoute(router, method, base + path, entries);
return router;
}
function createRouteRulesMatcher(rules, opts) {
const handlers = {
...ruleHandlers,
...opts?.handlers
};
requireOptInHandler(rules, handlers, "cache", "cache`/`swr", "Install `ocache` and pass `handlers: { cache }` from \"h3/rules/cache\", provide your own via `createCacheRuleHandler`, or pass `handlers: { cache: undefined }` to keep the rule data-only.");
requireOptInHandler(rules, handlers, "proxy", "proxy", "Pass `handlers: { proxy }` from \"h3/rules/proxy\", or `handlers: { proxy: undefined }` to keep the rule data-only.");
const router = createRulesRouter(rules, handlers, opts?.baseURL, opts?.preMerge);
const findRouteRules = (method, pathname) => findAllRoutes(router, method, pathname);
return createMatcherFromFind(findRouteRules, canOverrideRoute);
}
const canOverrideRoute = (currentRoute, incomingRoute) => {
if (currentRoute === incomingRoute) return true;
const rel = compareRoutes(currentRoute, incomingRoute);
return rel === "superset" || rel === "equal";
};
const OPAQUE_SEGMENT_RE = /[()\\]/;
const CONCRETE_SEGMENT_RE = /^[^:*(){}\\]+$/;
const ZERO_MATCHABLE_SEGMENT_RE = /^:.*[?*]$/;
const canOverrideRouteShape = (currentRoute, incomingRoute) => {
if (currentRoute === incomingRoute) return true;
const current = currentRoute.split("/");
const incoming = incomingRoute.split("/");
for (let i = 0; i < current.length; i++) {
const cur = current[i];
if (cur === "**") return i === current.length - 1 && incoming.length > i && !incoming.slice(i).some((segment) => ZERO_MATCHABLE_SEGMENT_RE.test(segment));
const inc = incoming[i];
if (inc === void 0) return false;
if (cur === inc) continue;
if ((cur === "*" || cur.startsWith(":") && !OPAQUE_SEGMENT_RE.test(cur)) && CONCRETE_SEGMENT_RE.test(inc)) continue;
return false;
}
return current.length === incoming.length;
};
function createMatcherFromFind(findRouteRules, canOverride = canOverrideRouteShape) {
return (method, pathname) => {
const rawLayers = findRouteRules(method, pathname);
let altLayers;
let hasAltMatch = false;
const readings = alternateReadings(pathname);
if (readings) {
altLayers = [];
for (const reading of readings) {
const layers = findRouteRules(method, reading);
if (layers?.length) hasAltMatch = true;
altLayers.push(layers);
}
}
if (!rawLayers?.length && !hasAltMatch) return {
routeRules: {},
matchedRules: {},
routeRuleMiddleware: []
};
const matchedRules = mergeMatchedRouteRules(rawLayers, altLayers, canOverride);
return {
routeRules: toRouteRules(matchedRules),
matchedRules,
routeRuleMiddleware: buildRouteRuleMiddleware(matchedRules)
};
};
}
function toRouteRules(matchedRules) {
const routeRules = Object.create(null);
for (const name in matchedRules) routeRules[name] = matchedRules[name].options;
return routeRules;
}
function buildRouteRuleMiddleware(matchedRules) {
const routeRuleMiddleware = [];
const rules = Object.entries(matchedRules);
if (rules.length > 1) rules.sort(compareRuleOrder);
for (const [, rule] of rules) {
if (!rule.handler) continue;
routeRuleMiddleware.push(rule.handler.handler(rule));
}
return routeRuleMiddleware;
}
function memoizeRouteRulesMatcher(matcher, opts) {
const max = opts?.max ?? 1024;
if (max <= 0) return matcher;
const memo = /* @__PURE__ */ new Map();
let hand;
const evict = () => {
for (;;) {
let next = hand?.next();
if (!next || next.done) {
hand = memo.values();
next = hand.next();
if (next.done) return;
}
const entry = next.value;
if (entry.visited) entry.visited = false;
else {
memo.delete(entry.key);
return;
}
}
};
return (method, pathname) => {
const key = method + " " + pathname;
const entry = memo.get(key);
if (entry) {
entry.visited = true;
return entry.result;
}
const result = matcher(method, pathname);
if (memo.size >= max) evict();
memo.set(key, {
key,
result,
visited: false
});
return result;
};
}
function alternateReadings(pathname) {
const decoded = decodedPath(pathname);
if (decoded === pathname && !needsCanonicalPasses(pathname)) return;
const readings = [];
for (const spelling of decoded === pathname ? [pathname] : [pathname, decoded]) {
if (!needsCanonicalPasses(spelling)) {
pushReading(readings, pathname, spelling);
continue;
}
const canonical = canonicalPath(spelling);
pushReading(readings, pathname, canonical);
const merged = mergedCanonicalPath(spelling, canonical);
if (merged !== void 0) pushReading(readings, pathname, merged);
}
return readings.length > 0 ? readings : void 0;
}
function pushReading(readings, pathname, reading) {
if (reading !== pathname && !readings.includes(reading)) readings.push(reading);
}
function requireOptInHandler(rules, handlers, name, label, hint) {
if (name in handlers) return;
for (const key in rules) if (rules[key][name]) throw new Error(`[h3] rules: rules use \`${label}\` (\`${key}\`) but no \`${name}\` handler is registered. ${hint}`);
}
const compareRuleOrder = (a, b) => orderWeight(a[1].handler) - orderWeight(b[1].handler) || (a[0] < b[0] ? -1 : 1);
function orderWeight(handler) {
return handler?.order ?? 0;
}
function withScopeBase(name, options, baseURL) {
if ((name === "redirect" || name === "proxy") && options !== null && typeof options === "object" && typeof options.base === "string") return {
...options,
base: baseURL + options.base
};
return options;
}
function normalizeRouteRules(config) {
const normalizedRules = {};
for (const key in config) {
const routeConfig = config[key];
const unknownMethod = unknownMethodPrefix(key);
if (unknownMethod !== void 0) throw new Error(`[h3] rules: \`${key}\` looks method-scoped but \`${unknownMethod}\` is not a recognized HTTP method — as a literal path this rule can never match. Use one of ${[...HTTP_METHODS].join(", ")}, remove the prefix for an all-methods rule, or add a leading \`/\` for a literal path`);
const { method, path: rawPath } = parseRouteKey(key);
const path = decodeRoutePattern(rawPath);
const canonicalKey = formatRouteKey(method, path);
validateBuiltinRules(routeConfig, canonicalKey);
const { redirect, proxy, cors, swr, cache, ...rest } = routeConfig;
const routeRules = rest;
if (redirect) {
const redirectOptions = {
to: "/",
status: 307,
...typeof redirect === "string" ? { to: redirect } : redirect
};
if (path.endsWith("/**")) redirectOptions.base = path.slice(0, -3);
assertNoHostSplat(redirectOptions.to, "redirect", canonicalKey);
routeRules.redirect = redirectOptions;
}
if (proxy) {
const proxyOptions = typeof proxy === "string" ? { to: proxy } : { ...proxy };
if (path.endsWith("/**")) proxyOptions.base = path.slice(0, -3);
assertNoHostSplat(proxyOptions.to, "proxy", canonicalKey);
routeRules.proxy = proxyOptions;
}
if (cors !== void 0 && cors !== false) {
const corsOptions = cors === true ? {} : { ...cors };
if (corsOptions.credentials === true && (!corsOptions.origin || corsOptions.origin === "*" || Array.isArray(corsOptions.origin) && corsOptions.origin.includes("*"))) throw new Error(`[h3] rules: \`cors\` rule for \`${canonicalKey}\` sets \`credentials: true\` with a wildcard origin — \`Access-Control-Allow-Origin: *\` is invalid for credentialed requests; set an explicit \`origin\` allowlist (or validation function)`);
routeRules.cors = corsOptions;
}
if (swr !== void 0 && swr !== false) {
const cacheOptions = { ...cache || void 0 };
cacheOptions.swr = true;
if (typeof swr === "number") cacheOptions.maxAge = swr;
routeRules.cache = cacheOptions;
} else if (swr === false && cache === void 0) routeRules.cache = false;
else if (cache !== void 0 && cache !== false) routeRules.cache = cache;
if (cache === false) routeRules.cache = false;
if (redirect === false) routeRules.redirect = false;
if (proxy === false) routeRules.proxy = false;
if (cors === false) routeRules.cors = false;
for (const name in routeRules) {
if (name === "__proto__" || name === "constructor" || name === "prototype") throw new Error(`[h3] rules: \`${name}\` is a reserved name and cannot be used as a rule for \`${canonicalKey}\``);
if (Array.isArray(routeRules[name])) throw new Error(`[h3] rules: \`${name}\` rule for \`${canonicalKey}\` is an array — rule options cannot be top-level arrays (ambiguous merge semantics); wrap it in an object`);
}
const existing = normalizedRules[canonicalKey];
if (existing) for (const [name, options] of Object.entries(routeRules)) existing[name] = mergeRuleOptions(existing[name], options);
else normalizedRules[canonicalKey] = routeRules;
}
return normalizedRules;
}
function assertNoHostSplat(to, name, canonicalKey) {
if (isHostPositionSplat(to)) throw new Error(`[h3] rules: \`${name}\` rule for \`${canonicalKey}\` has \`**\` in the target host (\`${to}\`) — \`**\` interpolates the matched path tail and must come after the target's host, in its path, query, or fragment`);
}
const BUILTIN_RULE_NAMES = [
"cache",
"headers",
"redirect",
"proxy",
"cors",
"swr"
];
function validateBuiltinRules(routeConfig, canonicalKey) {
for (const name of BUILTIN_RULE_NAMES) {
const value = routeConfig[name];
if (value || value === void 0 || value === false) continue;
if (name === "swr" && value === 0) continue;
throw new Error(`[h3] rules: \`${name}\` rule for \`${canonicalKey}\` is \`${String(value)}\` — use \`false\` to disable a rule inherited from a less-specific pattern, or provide options`);
}
}
export { HTTP_METHODS, buildRouteRuleMiddleware, cors, createMatcherFromFind, createRouteRulesMatcher, createRulesRouter, headers, memoizeRouteRulesMatcher, mergeMatchedRouteRules, normalizeRouteRules, parseRouteKey, redirect, ruleHandlers };