@stacksjs/router
Version:
The Stacks framework router.
1,234 lines (1,233 loc) • 57.7 kB
JavaScript
import { Middleware } from "./middleware";
import"./request-augmentation";
import process from "node:process";
import { Buffer } from "node:buffer";
import { existsSync } from "node:fs";
import { timingSafeEqual } from "node:crypto";
import { log, report } from "@stacksjs/logging";
import { path as p } from "@stacksjs/path";
import { UploadedFile } from "@stacksjs/storage";
import { applyRequestEnhancements, Router } from "@stacksjs/bun-router";
const ROUTER_INSTANCES_KEY = Symbol.for("@stacksjs/router:loaded-instances"), loadedRouterInstances = globalThis[ROUTER_INSTANCES_KEY] ??= new Set;
loadedRouterInstances.add(import.meta.path);
const MULTI_INSTANCE_WARNED_KEY = Symbol.for("@stacksjs/router:multi-instance-warned");
export function warnOnMultipleRouterInstances() {
if (loadedRouterInstances.size <= 1)
return !1;
const g = globalThis;
if (!g[MULTI_INSTANCE_WARNED_KEY]) {
g[MULTI_INSTANCE_WARNED_KEY] = !0;
const paths = [...loadedRouterInstances].map((p) => ` - ${p}`).join(`
`);
log.warn(`${loadedRouterInstances.size} distinct @stacksjs/router modules loaded in one process; they now share one route table (stacksjs/stacks#1982) so routing still works, but this is a duplicated install worth fixing. It usually means an app vendors storage/framework/core AND installs the published @stacksjs/* dist, and a tsconfig \`paths\` mapping (\`@stacksjs/* -> ./*/src\`) splits module resolution between core files and root files. See stacksjs/stacks#1975.
Loaded instances:
${paths}`);
}
return !0;
}
let __defaultsPkgRoot;
function resolveDefaultsPath(rel) {
const vendored = p.storagePath(`framework/defaults/${rel}`);
if (existsSync(vendored))
return vendored;
if (__defaultsPkgRoot === void 0)
try {
const pkgJson = Bun.resolveSync("@stacksjs/defaults/package.json", process.cwd());
__defaultsPkgRoot = pkgJson.slice(0, pkgJson.lastIndexOf("/"));
} catch {
__defaultsPkgRoot = null;
}
return __defaultsPkgRoot ? `${__defaultsPkgRoot}/${rel}` : vendored;
}
import { runWithRequest } from "./request-context";
import { isApiRequest, JSON_CONTENT_TYPE } from "./api-shape";
import { clearTrackedQueries, createErrorResponse, createMiddlewareErrorResponse } from "./error-handler";
import { rateLimit as enforceRateLimit } from "./rate-limit";
import { applySecurityHeaders } from "./security-headers";
import { isCursorPaginator, isPaginator, isSimplePaginator } from "@stacksjs/orm";
const csrfSkipRegistry = new Set, csrfRequireRegistry = new Set, routeRateLimitRegistry = new Map;
function rateLimitWindowToSeconds(window) {
if (typeof window === "number") {
if (!Number.isFinite(window) || window <= 0)
throw Error(`[Router] .rateLimit(): window must be a positive number of seconds, got ${window}`);
return Math.floor(window);
}
switch (window) {
case "second":
return 1;
case "minute":
return 60;
case "hour":
return 3600;
case "day":
return 86400;
default:
throw Error(`[Router] .rateLimit(): unknown period '${String(window)}'`);
}
}
class BoundedMap {
max;
map = new Map;
constructor(max) {
this.max = max;
}
get(key) {
return this.map.get(key);
}
has(key) {
return this.map.has(key);
}
set(key, value) {
if (this.map.has(key))
this.map.delete(key);
this.map.set(key, value);
if (this.map.size > this.max) {
const oldest = this.map.keys().next().value;
if (oldest !== void 0)
this.map.delete(oldest);
}
return this;
}
delete(key) {
return this.map.delete(key);
}
clear() {
this.map.clear();
}
get size() {
return this.map.size;
}
}
function isExposeRoutesAuthorized(req) {
const flag = process.env.STACKS_EXPOSE_ROUTES ?? "";
if (!flag)
return !((process.env.APP_ENV ?? "").toLowerCase() === "production" || process.env.NODE_ENV === "production");
if (flag === "1")
return !((process.env.APP_ENV ?? "").toLowerCase() === "production" || process.env.NODE_ENV === "production");
const url = new URL(req.url), submitted = req.headers.get("x-stacks-routes-token") || req.headers.get("X-Stacks-Routes-Token") || url.searchParams.get("token") || "";
if (typeof submitted !== "string" || submitted.length === 0 || submitted.length !== flag.length)
return !1;
try {
return timingSafeEqual(Buffer.from(submitted), Buffer.from(flag));
} catch {
return !1;
}
}
async function applyCorsIfConfigured(req, response) {
if (!req._corsConfig || !response)
return response;
try {
const { applyCorsHeaders } = await import(resolveDefaultsPath("app/Middleware/Cors.ts"));
return applyCorsHeaders(req, response, req._corsConfig);
} catch (err) {
log.warn("[router] CORS header injection failed", { error: err });
return response;
}
}
const ACTION_CACHE_MAX = 5000, actionSkipsCsrfCache = new BoundedMap(ACTION_CACHE_MAX), routeHandlerKeyRegistry = new BoundedMap(ACTION_CACHE_MAX), CSRF_PROTECTED_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]), namedRouteRegistry = new Map;
function compileNamedRoute(path) {
const paramNames = extractRouteParamNames(path), colonRegex = new Map;
for (const name of paramNames)
colonRegex.set(name, new RegExp(`(^|/):${name}(?=$|/)`, "g"));
return { path, paramNames, colonRegex };
}
function extractRouteParamNames(routePath) {
const names = new Set;
for (const m of routePath.matchAll(/\{(\w+)\}/g))
if (m[1])
names.add(m[1]);
for (const m of routePath.matchAll(/(?:^|\/):(\w+)(?=$|\/)/g))
if (m[1])
names.add(m[1]);
return [...names];
}
export function url(routeName, params = {}) {
const named = namedRouteRegistry.get(routeName);
if (!named)
throw Error(`Route '${routeName}' is not defined. Available routes: ${[...namedRouteRegistry.keys()].join(", ")}`);
const missing = named.paramNames.filter((name) => !(name in params) || params[name] === void 0);
if (missing.length > 0)
throw Error(`url('${routeName}'): missing required path param${missing.length > 1 ? "s" : ""} [${missing.join(", ")}] for path '${named.path}'. Pass them as the second argument: url('${routeName}', { ${named.paramNames.join(", ")} })`);
let appUrl;
try {
appUrl = process.env.APP_URL || "https://localhost";
} catch {
appUrl = "https://localhost";
}
appUrl = appUrl.replace(/\/$/, "");
if (!appUrl.startsWith("http"))
appUrl = `https://${appUrl}`;
let resolvedPath = named.path;
const queryParams = {};
for (const [key, value] of Object.entries(params)) {
const curly = `{${key}}`;
if (resolvedPath.includes(curly))
resolvedPath = resolvedPath.replaceAll(curly, encodeURIComponent(String(value)));
else {
const re = named.colonRegex.get(key);
if (re && re.test(resolvedPath)) {
re.lastIndex = 0;
resolvedPath = resolvedPath.replace(re, `$1${encodeURIComponent(String(value))}`);
} else
queryParams[key] = String(value);
}
}
const queryString = Object.keys(queryParams).length > 0 ? `?${new URLSearchParams(queryParams).toString()}` : "";
return `${appUrl}${resolvedPath}${queryString}`;
}
export function routeParams(routeName) {
const named = namedRouteRegistry.get(routeName);
return named ? [...named.paramNames] : [];
}
export function listRegisteredRoutes() {
const out = [], seen = new Set;
for (const key of routeMiddlewareRegistry.keys()) {
if (seen.has(key))
continue;
seen.add(key);
const idx = key.indexOf(":");
if (idx === -1)
continue;
const method = key.slice(0, idx), path = key.slice(idx + 1);
let routeName;
for (const [n, named] of namedRouteRegistry.entries())
if (named.path === path) {
routeName = n;
break;
}
out.push({ method, path, name: routeName });
}
return out.sort((a, b) => a.path.localeCompare(b.path));
}
const DEFAULT_MIDDLEWARE_PRIORITY = 10, _warnedInvalidPriorities = new Set;
function warnInvalidMiddlewarePriority(name, raw) {
const key = `${name}:${String(raw)}`;
if (_warnedInvalidPriorities.has(key))
return;
_warnedInvalidPriorities.add(key);
log.warn(`[Router] Middleware '${name}' declared an invalid priority (${String(raw)}). Priorities must be a finite non-negative number; falling back to default ${DEFAULT_MIDDLEWARE_PRIORITY}.`);
}
function adaptMiddlewareForBunRouter(middleware) {
if (middleware instanceof Middleware)
return middleware.toRouterHandler();
if (middleware && typeof middleware === "object" && typeof middleware.handle === "function" && typeof middleware !== "function") {
const handle = middleware.handle.bind(middleware);
return async (req, next) => {
try {
await handle(req);
} catch (thrown) {
if (thrown instanceof Response)
return thrown;
throw thrown;
}
return next();
};
}
return middleware;
}
const middlewareCache = new Map;
let middlewareAliasesPromise = null;
async function getMiddlewareAliases() {
if (middlewareAliasesPromise)
return middlewareAliasesPromise;
middlewareAliasesPromise = (async () => {
try {
return (await import(p.appPath("Middleware.ts"))).default || {};
} catch {
try {
return (await import(resolveDefaultsPath("app/Middleware.ts"))).default || {};
} catch {
return {};
}
}
})();
return middlewareAliasesPromise;
}
const PASCAL_SPLIT_REGEX = /[-_\s]+/, pascalCaseCache = new Map;
function toPascalCase(input) {
if (!input)
return input;
const cached = pascalCaseCache.get(input);
if (cached !== void 0)
return cached;
const out = input.split(PASCAL_SPLIT_REGEX).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
pascalCaseCache.set(input, out);
return out;
}
async function resolveMiddlewareName(name) {
const resolved = (await getMiddlewareAliases())[name] || toPascalCase(name);
log.debug(`[middleware] Resolved: ${name} \u2192 ${resolved}`);
return resolved;
}
async function loadMiddleware(name) {
if (middlewareCache.has(name))
return middlewareCache.get(name) ?? null;
const className = await resolveMiddlewareName(name);
let userPathError;
try {
const userPath = p.appPath(`Middleware/${className}.ts`), handler = (await import(userPath)).default ?? null;
if (!handler || typeof handler.handle !== "function") {
log.error(`[Router] Middleware '${name}' resolved to ${userPath}, but the file has no default export with a handle() method`);
middlewareCache.set(name, null);
return null;
}
middlewareCache.set(name, handler);
return handler;
} catch (err) {
userPathError = err;
}
try {
const defaultPath = resolveDefaultsPath(`app/Middleware/${className}.ts`), handler = (await import(defaultPath)).default ?? null;
if (!handler || typeof handler.handle !== "function") {
log.error(`[Router] Middleware '${name}' resolved to ${defaultPath}, but the file has no default export with a handle() method`);
middlewareCache.set(name, null);
return null;
}
middlewareCache.set(name, handler);
return handler;
} catch (err) {
const userMsg = userPathError instanceof Error ? userPathError.message : String(userPathError);
log.error(`[Router] Failed to load middleware '${name}' (resolved to '${className}'). app/Middleware: ${userMsg}; defaults:`, err);
return null;
}
}
export function clearMiddlewareCache() {
middlewareCache.clear();
middlewareAliasesPromise = null;
actionSkipsCsrfCache.clear();
routeHandlerKeyRegistry.clear();
}
export function installMiddlewareHotReload() {
if (process.env.APP_ENV === "production" || process.env.NODE_ENV === "production")
return () => {};
let fsWatchers = [];
(async () => {
try {
const fs = await import("node:fs"), targets = [
p.appPath("Middleware"),
p.appPath("Middleware.ts")
];
for (const target of targets)
try {
if (!fs.existsSync(target))
continue;
const w = fs.watch(target, { recursive: !0 }, () => {
log.debug("[middleware] hot-reload: clearing cache");
clearMiddlewareCache();
});
fsWatchers.push(w);
} catch {}
} catch {}
})();
return () => {
for (const w of fsWatchers)
try {
w.close();
} catch {}
fsWatchers = [];
};
}
const routeMiddlewareRegistry = new Map, routeApiResponseRegistry = new Set;
function parseMiddlewareName(middleware) {
const colonIndex = middleware.indexOf(":");
if (colonIndex === -1)
return { name: middleware };
return {
name: middleware.substring(0, colonIndex),
params: middleware.substring(colonIndex + 1)
};
}
export async function findUnresolvableRouteMiddleware() {
const usage = new Map;
for (const [routeKey, entries] of routeMiddlewareRegistry)
for (const entry of entries) {
const { name } = parseMiddlewareName(entry), routes = usage.get(name) ?? [];
routes.push(routeKey);
usage.set(name, routes);
}
if (!usage.has("csrf"))
usage.set("csrf", ["(auto-injected on POST/PUT/PATCH/DELETE)"]);
const unresolvable = [];
for (const [alias, routes] of usage) {
const handler = await loadMiddleware(alias);
if (!handler || typeof handler.handle !== "function")
unresolvable.push({ alias, routes });
}
return unresolvable;
}
export async function assertRouteMiddlewareResolvable() {
const unresolvable = await findUnresolvableRouteMiddleware();
if (unresolvable.length === 0)
return;
const detail = unresolvable.map((u) => `"${u.alias}" (used by ${u.routes.join(", ")})`).join("; ");
throw Error(`[Router] Unresolvable middleware alias(es): ${detail}. Check the alias map in app/Middleware.ts or add app/Middleware/<Class>.ts.`);
}
function createMiddlewareHandler(routeKey, handler) {
const wrappedBase = wrapHandler(handler, !0);
let actionPrefetch = null;
if (typeof handler === "string") {
const method = routeKey.slice(0, routeKey.indexOf(":")).toUpperCase();
if (CSRF_PROTECTED_METHODS.has(method))
actionPrefetch = resolveStringHandler(handler).then(() => {
return;
}).catch(() => {
return;
});
}
return async (req) => {
try {
await parseRequestBody(req);
} catch (err) {
const error = err instanceof Error ? err : Error(String(err));
return createMiddlewareErrorResponse(error, req);
}
const enhancedReq = enhanceRequest(req);
if (actionPrefetch)
await actionPrefetch;
if (routeApiResponseRegistry.has(routeKey))
req._forceJson = !0;
return runWithRequest(enhancedReq, async () => {
const rl = routeRateLimitRegistry.get(routeKey);
if (rl)
try {
await enforceRateLimit(routeKey, rl.max).over(rl.windowSeconds);
} catch (err) {
return createMiddlewareErrorResponse(err, req);
}
const userMiddleware = routeMiddlewareRegistry.get(routeKey) || [], method = req.method.toUpperCase(), middlewareEntries = [...userMiddleware], alreadyHasCsrf = userMiddleware.some((m) => m === "csrf" || m.startsWith("csrf:")), routeSkipped = csrfSkipRegistry.has(routeKey), routeRequired = csrfRequireRegistry.has(routeKey), handlerKey = routeHandlerKeyRegistry.get(routeKey), actionSkipped = handlerKey ? actionSkipsCsrfCache.get(handlerKey) === !0 : !1;
if (CSRF_PROTECTED_METHODS.has(method) && !alreadyHasCsrf && (routeRequired || !routeSkipped && !actionSkipped))
middlewareEntries.unshift("csrf");
if (middlewareEntries.length > 0) {
const lvl = (process.env.LOG_LEVEL || "info").toLowerCase();
if (lvl !== "info" && lvl !== "warn" && lvl !== "error") {
const schemeEnd = req.url.indexOf("://"), pathStart = schemeEnd === -1 ? 0 : req.url.indexOf("/", schemeEnd + 3), q = req.url.indexOf("?", pathStart < 0 ? 0 : pathStart), urlPath = pathStart < 0 ? "/" : req.url.slice(pathStart, q === -1 ? void 0 : q);
log.debug(`[middleware] Executing chain: [${middlewareEntries.join(", ")}] for ${method} ${urlPath}`);
}
}
const resolved = [];
for (const middlewareEntry of middlewareEntries) {
const { name: middlewareName, params } = parseMiddlewareName(middlewareEntry);
if (params) {
enhancedReq._middlewareParams = enhancedReq._middlewareParams || {};
enhancedReq._middlewareParams[middlewareName] = params;
}
const middleware = await loadMiddleware(middlewareName);
if (!middleware || typeof middleware.handle !== "function") {
log.error(`[Router] Middleware '${middlewareName}' on ${routeKey} could not be resolved \u2014 failing closed`);
const failClosedError = Error(`Middleware '${middlewareName}' could not be resolved`), failClosedResponse = await createErrorResponse(failClosedError, enhancedReq, { status: 500 });
return await applyCorsIfConfigured(enhancedReq, failClosedResponse);
}
const rawPriority = middleware.priority;
let priority = DEFAULT_MIDDLEWARE_PRIORITY;
if (typeof rawPriority === "number" && Number.isFinite(rawPriority) && rawPriority >= 0)
priority = rawPriority;
else if (rawPriority !== void 0)
warnInvalidMiddlewarePriority(middlewareName, rawPriority);
resolved.push({ name: middlewareName, handler: middleware, priority });
}
resolved.sort((a, b) => a.priority - b.priority);
const middlewareTimings = [];
for (const { name: middlewareName, handler: middleware } of resolved) {
const mwStart = process.hrtime.bigint();
let mwTimer;
try {
const MIDDLEWARE_TIMEOUT_MS = 30000, timeoutPromise = new Promise((_, reject) => {
mwTimer = setTimeout(() => reject(Error(`Middleware '${middlewareName}' exceeded ${MIDDLEWARE_TIMEOUT_MS}ms`)), MIDDLEWARE_TIMEOUT_MS);
});
await Promise.race([middleware.handle(enhancedReq), timeoutPromise]);
const elapsedMs = Number(process.hrtime.bigint() - mwStart) / 1e6;
middlewareTimings.push({ name: middlewareName, ms: elapsedMs });
} catch (error) {
const elapsedMs = Number(process.hrtime.bigint() - mwStart) / 1e6;
middlewareTimings.push({ name: middlewareName, ms: elapsedMs });
log.debug(`[middleware] Blocked by: ${middlewareName}`);
if (error instanceof Response) {
try {
const { _requestId: reqId, _startNs: startNs } = enhancedReq, total = startNs != null ? Number(process.hrtime.bigint() - startNs) / 1e6 : null, parts = total != null ? [`total;dur=${total.toFixed(1)}`] : [];
for (const t of middlewareTimings) {
const safeName = t.name.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 32);
parts.push(`mw_${safeName};dur=${t.ms.toFixed(1)}`);
}
if (parts.length > 0)
error.headers.set("Server-Timing", parts.join(", "));
if (reqId)
error.headers.set("X-Request-ID", reqId);
} catch {}
return await applyCorsIfConfigured(enhancedReq, error);
}
const err = error instanceof Error ? error : Error(String(error)), errorResponse = "statusCode" in err || "status" in err ? await createMiddlewareErrorResponse(err, enhancedReq) : await (() => {
log.error(`[Router] Middleware '${middlewareName}' threw an unexpected error:`, err);
return createErrorResponse(err, enhancedReq, { status: 500 });
})();
try {
const { _requestId: reqId, _startNs: startNs } = enhancedReq, total = startNs != null ? Number(process.hrtime.bigint() - startNs) / 1e6 : null, parts = total != null ? [`total;dur=${total.toFixed(1)}`] : [];
for (const t of middlewareTimings) {
const safeName = t.name.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 32);
parts.push(`mw_${safeName};dur=${t.ms.toFixed(1)}`);
}
if (parts.length > 0)
errorResponse.headers.set("Server-Timing", parts.join(", "));
if (reqId)
errorResponse.headers.set("X-Request-ID", reqId);
} catch {}
return await applyCorsIfConfigured(enhancedReq, errorResponse);
} finally {
clearTimeout(mwTimer);
}
}
let response = await wrappedBase(enhancedReq);
clearTrackedQueries();
if (response) {
if (req.method === "GET" || req.method === "HEAD" || req.method === "OPTIONS")
try {
const { seedCsrfCookieIfMissing } = await import(resolveDefaultsPath("app/Middleware/Csrf.ts"));
response = seedCsrfCookieIfMissing(enhancedReq, response);
} catch (err) {
log.warn("[router] CSRF cookie seeding failed", { error: err });
}
}
if (response)
response = await applyCorsIfConfigured(enhancedReq, response);
const { _requestId: reqId, _startNs: startNs } = enhancedReq, durMs = startNs != null ? Number(process.hrtime.bigint() - startNs) / 1e6 : null, setHeaders = (h) => {
if (reqId)
h.set("X-Request-ID", reqId);
if (durMs != null) {
const parts = [`total;dur=${durMs.toFixed(1)}`];
for (const t of middlewareTimings) {
const safeName = t.name.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 32);
parts.push(`mw_${safeName};dur=${t.ms.toFixed(1)}`);
}
h.set("Server-Timing", parts.join(", "));
}
applySecurityHeaders(h);
};
if (response && typeof response.headers?.set === "function") {
if (response.status >= 400 && (response.headers.get("content-type") || "").includes("json") && reqId)
try {
const text = await response.clone().text(), parsed = JSON.parse(text);
if (parsed && typeof parsed === "object") {
const newHeaders = new Headers(response.headers);
setHeaders(newHeaders);
return new Response(JSON.stringify({ ...parsed, request_id: reqId }), {
status: response.status,
statusText: response.statusText,
headers: newHeaders
});
}
} catch {}
try {
setHeaders(response.headers);
} catch {
try {
const cloned = response.clone(), newHeaders = new Headers(response.headers);
setHeaders(newHeaders);
return new Response(cloned.body, {
status: response.status,
statusText: response.statusText,
headers: newHeaders
});
} catch {}
}
}
if (enhancedReq._compress === !0 && response)
try {
const { applyCompression } = await import(resolveDefaultsPath("app/Middleware/Compress.ts"));
return await applyCompression(enhancedReq, response);
} catch (err) {
log.warn(`[router] Compression failed; sending uncompressed response: ${err instanceof Error ? err.message : String(err)}`);
}
return response;
});
};
}
function createChainableRoute(routeKey) {
if (!routeMiddlewareRegistry.has(routeKey))
routeMiddlewareRegistry.set(routeKey, []);
const routePath = routeKey.includes(":") ? routeKey.substring(routeKey.indexOf(":") + 1) : routeKey, chain = {
middleware(name) {
const middlewareList = routeMiddlewareRegistry.get(routeKey);
if (middlewareList)
middlewareList.push(name);
return chain;
},
name(routeName) {
namedRouteRegistry.set(routeName, compileNamedRoute(routePath));
return chain;
},
skipCsrf() {
csrfSkipRegistry.add(routeKey);
csrfRequireRegistry.delete(routeKey);
return chain;
},
requireCsrf() {
csrfRequireRegistry.add(routeKey);
csrfSkipRegistry.delete(routeKey);
return chain;
},
rateLimit(max, window) {
if (!Number.isFinite(max) || max <= 0)
throw Error(`[Router] .rateLimit(): max must be a positive number, got ${String(max)}`);
const windowSeconds = rateLimitWindowToSeconds(window);
routeRateLimitRegistry.set(routeKey, { max: Math.floor(max), windowSeconds });
return chain;
}
};
return chain;
}
async function fileExists(path) {
try {
return await Bun.file(path).exists();
} catch {
return !1;
}
}
function assertSafeHandlerPath(handlerPath) {
if (typeof handlerPath !== "string" || handlerPath.length === 0)
throw Error(`[Router] Refusing to resolve handler '${String(handlerPath)}': empty or non-string`);
if (handlerPath.includes("\x00"))
throw Error("[Router] Refusing to resolve handler with null byte");
if (handlerPath.startsWith("/") || /^[A-Za-z]:[\\/]/.test(handlerPath))
throw Error(`[Router] Refusing to resolve absolute handler path '${handlerPath}'`);
if (handlerPath.split(/[/\\]/).some((s) => s === ".."))
throw Error(`[Router] Refusing to resolve handler path '${handlerPath}' (contains '..' segment)`);
}
const _moduleImportCache = new Map;
function cachedImport(fullPath) {
let p = _moduleImportCache.get(fullPath);
if (!p) {
p = import(fullPath);
_moduleImportCache.set(fullPath, p);
}
return p;
}
const _resolvedHandlerCache = new Map;
function resolveStringHandler(handlerPath) {
let resolved = _resolvedHandlerCache.get(handlerPath);
if (!resolved) {
resolved = resolveStringHandlerUncached(handlerPath);
_resolvedHandlerCache.set(handlerPath, resolved);
resolved.catch(() => _resolvedHandlerCache.delete(handlerPath));
}
return resolved;
}
async function resolveStringHandlerUncached(handlerPath) {
assertSafeHandlerPath(handlerPath);
let modulePath = handlerPath;
modulePath = modulePath.endsWith(".ts") ? modulePath.slice(0, -3) : modulePath;
if (modulePath.includes("Controller")) {
const [controllerPath, methodName = "index"] = modulePath.split("@"), userPath = p.appPath(`${controllerPath}.ts`), defaultPath = resolveDefaultsPath(`app/${controllerPath}.ts`), fullPath = await fileExists(userPath) ? userPath : defaultPath;
try {
const controller = await cachedImport(fullPath);
if (!controller.default || typeof controller.default !== "function")
throw Error(`Controller ${controllerPath} does not export a default class`);
const instance = new controller.default;
if (typeof instance[methodName] !== "function")
throw Error(`Method ${methodName} not found in controller ${controllerPath}`);
return async (req) => {
const result = await instance[methodName](req);
return formatResult(result, req);
};
} catch (error) {
log.error(`[Router] Failed to load controller '${fullPath}':`, error);
throw error;
}
}
let fullPath;
if (modulePath.includes("storage/framework/orm"))
fullPath = modulePath;
else if (modulePath.includes("OrmAction"))
fullPath = p.storagePath(`framework/actions/src/${modulePath}.ts`);
else if (modulePath.includes("Actions")) {
const userPath = p.projectPath(`app/${modulePath}.ts`), defaultPath = resolveDefaultsPath(`app/${modulePath}.ts`);
fullPath = await fileExists(userPath) ? userPath : defaultPath;
} else {
const userPath = p.appPath(`${modulePath}.ts`), defaultPath = resolveDefaultsPath(`app/${modulePath}.ts`);
fullPath = await fileExists(userPath) ? userPath : defaultPath;
}
try {
const action = (await cachedImport(fullPath)).default;
if (!action)
throw Error(`Action '${handlerPath}' has no default export`);
if (typeof action.handle !== "function") {
log.error(`[Router] Action '${handlerPath}' structure:`, Object.keys(action));
throw Error(`Action '${handlerPath}' has no handle() method. Got: ${typeof action.handle}`);
}
const actionSkipsCsrf = action.skipCsrf === !0 || action.csrf === !1;
actionSkipsCsrfCache.set(handlerPath, actionSkipsCsrf);
const actionForcesJson = action.apiResponse === !0;
return async (req) => {
if (actionSkipsCsrf)
req._skipCsrf = !0;
if (actionForcesJson)
req._forceJson = !0;
try {
if (action.validations) {
const validationResult = await validateActionInput(req, action.validations);
if (!validationResult.valid)
return Response.json({ error: "Validation failed", errors: validationResult.errors }, { status: 422 });
}
if (typeof action.authorize === "function") {
const auth = await action.authorize(req);
if (auth instanceof Response)
return auth;
if (auth === !1)
return Response.json({ error: "Forbidden" }, { status: 403 });
}
if (typeof action.before === "function") {
const pre = await action.before(req);
if (pre instanceof Response)
return pre;
}
const result = await action.handle(req);
return formatResult(result, req);
} catch (handleError) {
report(handleError, { label: `[Router] action.handle() for '${handlerPath}'` });
throw handleError;
}
};
} catch (importError) {
log.error(`[Router] Failed to import action '${fullPath}':`, importError);
throw importError;
}
}
export async function validateActionInput(req, validations) {
const errors = {}, input = await getRequestInput(req, validations);
for (const [field, validation] of Object.entries(validations)) {
const value = input[field];
let result;
try {
result = validation.rule.validate(value);
} catch {
result = { valid: !1, errors: [{ message: `${field} validation failed` }] };
}
if (!result.valid) {
const fieldErrors = [], label = field.replace(/[-_]+/g, " ").replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, (c) => c.toUpperCase()), decorate = (msg) => msg.toLowerCase().startsWith(field.toLowerCase()) || msg.includes(label) ? msg : `${label} ${msg}`;
if (result.errors && result.errors.length > 0)
if (validation.message) {
const firstMessage = result.errors[0]?.message ?? "";
fieldErrors.push(typeof validation.message === "string" ? validation.message : validation.message[field] || decorate(firstMessage));
} else
result.errors.forEach((err) => fieldErrors.push(decorate(err.message)));
else
fieldErrors.push(validation.message ? typeof validation.message === "string" ? validation.message : `${label} is invalid` : `${label} is invalid`);
errors[field] = fieldErrors;
}
}
return {
valid: Object.keys(errors).length === 0,
errors
};
}
async function getRequestInput(req, validations) {
const input = {}, q = req.query;
if (q)
for (const key in q)
input[key] = q[key];
else
new URL(req.url).searchParams.forEach((value, key) => {
input[key] = value;
});
if (req.params)
Object.assign(input, req.params);
if (req.jsonBody && typeof req.jsonBody === "object")
Object.assign(input, req.jsonBody);
else if (req.formBody && typeof req.formBody === "object")
Object.assign(input, req.formBody);
if (typeof req.allFiles === "function")
try {
const files = req.allFiles();
for (const key of Object.keys(files ?? {}))
if (!(key in input))
input[key] = files[key];
} catch {}
if (!validations)
return input;
for (const [field, validation] of Object.entries(validations)) {
const value = input[field];
if (typeof value !== "string")
continue;
const validatorName = validation.rule?.name;
if (validatorName === "number") {
const n = Number(value);
if (Number.isFinite(n))
input[field] = n;
} else if (validatorName === "boolean") {
if (value === "true" || value === "1")
input[field] = !0;
else if (value === "false" || value === "0")
input[field] = !1;
}
}
return input;
}
function formatResult(result, req) {
if (result instanceof Response)
return result;
if (result instanceof ReadableStream)
return new Response(result, {
headers: { "Content-Type": "application/octet-stream" }
});
const apiShaped = req._forceJson === !0 || isApiRequest(req);
if (result === null || result === void 0)
return apiShaped ? new Response(null, { status: 204 }) : new Response("", { status: 200 });
if (typeof result === "object") {
const linkHeader = buildPaginatorLinkHeader(result);
if (linkHeader)
return Response.json(result, { headers: { Link: linkHeader } });
return Response.json(result);
}
if (apiShaped)
return Response.json(result);
return new Response(String(result), {
headers: { "Content-Type": "text/plain; charset=utf-8" }
});
}
function buildPaginatorLinkHeader(value) {
if (!isPaginator(value) && !isSimplePaginator(value) && !isCursorPaginator(value))
return null;
const v = value, parts = [];
if (v.prev_page_url)
parts.push(`<${v.prev_page_url}>; rel="prev"`);
if (v.next_page_url)
parts.push(`<${v.next_page_url}>; rel="next"`);
if (v.first_page_url)
parts.push(`<${v.first_page_url}>; rel="first"`);
if (v.last_page_url)
parts.push(`<${v.last_page_url}>; rel="last"`);
return parts.length > 0 ? parts.join(", ") : null;
}
export function stream(source, options = {}) {
const baseHeaders = {};
if (options.type === "sse") {
baseHeaders["Content-Type"] = "text/event-stream; charset=utf-8";
baseHeaders["Cache-Control"] = "no-cache";
baseHeaders.Connection = "keep-alive";
} else if (options.type === "ndjson")
baseHeaders["Content-Type"] = "application/x-ndjson; charset=utf-8";
else
baseHeaders["Content-Type"] = options.contentType ?? "application/octet-stream";
const body = source instanceof ReadableStream ? source : new ReadableStream({
async start(controller) {
try {
for await (const chunk of source)
controller.enqueue(typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk);
controller.close();
} catch (err) {
controller.error(err);
}
}
}), merged = new Headers(baseHeaders);
if (options.headers)
new Headers(options.headers).forEach((value, key) => merged.set(key, value));
return new Response(body, { status: options.status ?? 200, headers: merged });
}
function getAllInputFor(req) {
const cached = req._allInputCache;
if (cached)
return cached;
const input = {}, query = req.query;
if (query)
for (const key in query)
input[key] = query[key];
if (req.jsonBody && typeof req.jsonBody === "object")
Object.assign(input, req.jsonBody);
if (req.formBody && typeof req.formBody === "object")
Object.assign(input, req.formBody);
if (req.params && typeof req.params === "object")
Object.assign(input, req.params);
req._allInputCache = input;
return input;
}
const REQUEST_METHODS = {
get(key, defaultValue) {
const value = getAllInputFor(this)[key];
return value !== void 0 ? value : defaultValue;
},
input(key, defaultValue) {
const value = getAllInputFor(this)[key];
return value !== void 0 ? value : defaultValue;
},
all() {
return getAllInputFor(this);
},
only(keys) {
const input = getAllInputFor(this), result = {};
for (const key of keys)
if (key in input)
result[key] = input[key];
return result;
},
except(keys) {
const result = { ...getAllInputFor(this) };
for (const key of keys)
delete result[key];
return result;
},
has(key) {
const input = getAllInputFor(this);
if (Array.isArray(key))
return key.every((k) => (k in input) && input[k] !== void 0);
return key in input && input[key] !== void 0;
},
hasAny(keys) {
const input = getAllInputFor(this);
return keys.some((k) => (k in input) && input[k] !== void 0);
},
filled(key) {
const input = getAllInputFor(this), isFilled = (k) => {
const value = input[k];
return value !== void 0 && value !== null && value !== "" && !(Array.isArray(value) && value.length === 0);
};
if (Array.isArray(key))
return key.every(isFilled);
return isFilled(key);
},
missing(key) {
const input = getAllInputFor(this);
if (Array.isArray(key))
return key.every((k) => !(k in input) || input[k] === void 0);
return !(key in input) || input[key] === void 0;
},
string(key, defaultValue = "") {
const value = getAllInputFor(this)[key];
return value !== void 0 && value !== null ? String(value) : defaultValue;
},
integer(key, defaultValue = 0) {
const value = getAllInputFor(this)[key];
if (value === void 0 || value === null || value === "")
return defaultValue;
if (typeof value === "number")
return Number.isFinite(value) ? Math.trunc(value) : defaultValue;
const str = String(value).trim();
if (!/^-?\d+$/.test(str))
return defaultValue;
const parsed = Number.parseInt(str, 10);
return Number.isFinite(parsed) ? parsed : defaultValue;
},
float(key, defaultValue = 0) {
const value = getAllInputFor(this)[key];
if (value === void 0 || value === null || value === "")
return defaultValue;
if (typeof value === "number")
return Number.isFinite(value) ? value : defaultValue;
const str = String(value).trim();
if (!/^-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?$/.test(str))
return defaultValue;
const parsed = Number.parseFloat(str);
return Number.isFinite(parsed) ? parsed : defaultValue;
},
boolean(key, defaultValue = !1) {
const value = getAllInputFor(this)[key];
if (value === void 0 || value === null)
return defaultValue;
if (typeof value === "boolean")
return value;
if (value === "true" || value === "1" || value === 1)
return !0;
if (value === "false" || value === "0" || value === 0)
return !1;
return defaultValue;
},
array(key) {
const value = getAllInputFor(this)[key];
if (Array.isArray(value))
return value;
return value !== void 0 && value !== null ? [value] : [];
},
file(key) {
const file = (this.files || {})[key];
if (!file)
return null;
const rawFile = Array.isArray(file) ? file[0] : file;
return rawFile ? new UploadedFile(rawFile) : null;
},
getFiles(key) {
const file = (this.files || {})[key];
if (!file)
return [];
return (Array.isArray(file) ? file : [file]).map((f) => new UploadedFile(f));
},
hasFile(key) {
const files = this.files || {};
return key in files && files[key] !== void 0;
},
allFiles() {
const files = this.files || {}, result = {};
for (const [key, value] of Object.entries(files))
if (Array.isArray(value))
result[key] = value.map((f) => new UploadedFile(f));
else
result[key] = new UploadedFile(value);
return result;
},
async user() {
return this._authenticatedUser;
},
async userToken() {
return this._currentAccessToken;
},
async tokenCan(ability) {
if (typeof ability !== "string" || ability.length === 0)
return !1;
const token = this._currentAccessToken;
if (!token || typeof token !== "object")
return !1;
if (!Array.isArray(token.abilities))
return !1;
if (token.abilities.includes("*"))
return !0;
return token.abilities.includes(ability);
},
async tokenCant(ability) {
return !await this.tokenCan(ability);
},
async can(ability, ...args) {
if (typeof ability !== "string" || ability.length === 0)
return !1;
const { Gate } = await import("@stacksjs/auth"), user = this._authenticatedUser ?? null;
return Gate.allows(ability, user, ...args);
},
async cannot(ability, ...args) {
return !await this.can(ability, ...args);
},
async authorize(ability, ...args) {
const { Gate } = await import("@stacksjs/auth"), user = this._authenticatedUser ?? null;
await Gate.authorize(ability, user, ...args);
}
};
export function enhanceRequest(req) {
applyRequestEnhancements(req, req.params || {});
let query = req.query;
if (!query) {
const url = new URL(req.url);
query = {};
url.searchParams.forEach((value, key) => {
query[key] = value;
});
req.query = query;
}
Object.assign(req, REQUEST_METHODS);
return req;
}
function wrapHandler(handler, skipParsing = !1) {
if (typeof handler === "string") {
const handlerPath = handler;
return async (req) => {
try {
if (!skipParsing) {
await parseRequestBody(req);
req = enhanceRequest(req);
}
return await (await resolveStringHandler(handlerPath))(req);
} catch (error) {
report(error, { label: `[Router] ${handlerPath}` });
const rawStatus = error?.statusCode ?? error?.status, status = typeof rawStatus === "number" && Number.isInteger(rawStatus) && rawStatus >= 400 && rawStatus < 600 ? rawStatus : void 0;
return await createErrorResponse(error instanceof Error ? error : Error(String(error)), req, { handlerPath, status });
}
};
}
const fn = handler;
return async (req) => {
const result = await fn(req);
return formatResult(result, req);
};
}
async function parseRequestBody(req) {
if (req._bodyParsed)
return;
req._bodyParsed = !0;
const contentType = req.headers.get("content-type") || "";
try {
if (JSON_CONTENT_TYPE.test(contentType)) {
const raw = await req.clone().text();
req._rawBody = raw;
if (raw.length === 0)
req.jsonBody = {};
else
try {
const body = JSON.parse(raw);
req.jsonBody = body && typeof body === "object" ? body : {};
} catch (parseErr) {
const message = parseErr instanceof Error ? parseErr.message : "Invalid JSON", { HttpError } = await import("@stacksjs/error-handling");
throw new HttpError(400, `Invalid JSON body: ${message}`);
}
} else if (contentType.includes("application/x-www-form-urlencoded")) {
const text = await req.clone().text(), params = new URLSearchParams(text), formBody = {};
params.forEach((value, key) => {
formBody[key] = value;
});
req.formBody = formBody;
} else if (contentType.includes("multipart/form-data")) {
const formData = await req.clone().formData(), formBody = {}, files = {};
formData.forEach((value, key) => {
if (value instanceof File)
if (files[key])
if (Array.isArray(files[key]))
files[key].push(value);
else
files[key] = [files[key], value];
else
files[key] = value;
else
formBody[key] = value;
});
Reflect.set(req, "formBody", formBody);
Reflect.set(req, "files", files);
}
} catch (e) {
if (typeof (e?.status ?? e?.statusCode) === "number")
throw e;
log.debug("[stacks-router] Body parsing failed:", e);
}
}
export function createStacksRouter(config = {}) {
const bunRouter = new Router({
verbose: config.verbose ?? !1
});
let currentPrefix = "", currentGroupMiddleware = [], currentGroupApiResponse = !1;
function registerRoute(method, path, _handler) {
const fullPath = currentPrefix + path, routeKey = `${method}:${fullPath}`;
log.debug(`[router] ${method} ${fullPath} \u2192 ${typeof _handler === "string" ? _handler : "function"}`);
if (currentGroupMiddleware.length > 0)
routeMiddlewareRegistry.set(routeKey, [...currentGroupMiddleware]);
if (currentGroupApiResponse)
routeApiResponseRegistry.add(routeKey);
if (typeof _handler === "string")
routeHandlerKeyRegistry.set(routeKey, _handler);
return { fullPath, routeKey };
}
const stacksRouter = {
bunRouter,
get routes() {
return bunRouter.routes;
},
get(path, handler) {
const { fullPath, routeKey } = registerRoute("GET", path, handler);
bunRouter.get(fullPath, createMiddlewareHandler(routeKey, handler));
return createChainableRoute(routeKey);
},
post(path, handler) {
const { fullPath, routeKey } = registerRoute("POST", path, handler);
bunRouter.post(fullPath, createMiddlewareHandler(routeKey, handler));
return createChainableRoute(routeKey);
},
put(path, handler) {
const { fullPath, routeKey } = registerRoute("PUT", path, handler);
bunRouter.put(fullPath, createMiddlewareHandler(routeKey, handler));
return createChainableRoute(routeKey);
},
patch(path, handler) {
const { fullPath, routeKey } = registerRoute("PATCH", path, handler);
bunRouter.patch(fullPath, createMiddlewareHandler(routeKey, handler));
return createChainableRoute(routeKey);
},
delete(path, handler) {
const { fullPath, routeKey } = registerRoute("DELETE", path, handler);
bunRouter.delete(fullPath, createMiddlewareHandler(routeKey, handler));
return createChainableRoute(routeKey);
},
options(path, handler) {
const { fullPath, routeKey } = registerRoute("OPTIONS", path, handler);
bunRouter.options(fullPath, createMiddlewareHandler(routeKey, handler));
return createChainableRoute(routeKey);
},
group(options, callback) {
const previousPrefix = currentPrefix, previousMiddleware = [...currentGroupMiddleware], previousApiResponse = currentGroupApiResponse;
if (options.prefix)
currentPrefix = previousPrefix + options.prefix;
const middlewareList = options.middleware ? Array.isArray(options.middleware) ? options.middleware : [options.middleware] : void 0;
if (middlewareList)
currentGroupMiddleware = [...currentGroupMiddleware, ...middlewareList];
if (options.apiResponse === !0)
currentGroupApiResponse = !0;
log.debug(`[router] Entering group: prefix=${options.prefix || "/"} middleware=[${middlewareList?.join(", ") || ""}]${currentGroupApiResponse ? " apiResponse=true" : ""}`);
const result = callback();
if (result instanceof Promise)
return result.then(() => {
currentPrefix = previousPrefix;
currentGroupMiddleware = previousMiddleware;
currentGroupApiResponse = previousApiResponse;
return stacksRouter;
}).catch((err) => {
currentPrefix = previousPrefix;
currentGroupMiddleware = previousMiddleware;
currentGroupApiResponse = previousApiResponse;
throw err;
});
currentPrefix = previousPrefix;
currentGroupMiddleware = previousMiddleware;
currentGroupApiResponse = previousApiResponse;
return stacksRouter;
},
resource(name, handler, options) {
const actions = ["index", "store", "show", "update", "destroy"], activeActions = options?.only ? actions.filter((a) => options.only.includes(a)) : options?.except ? actions.filter((a) => !options.except.includes(a)) : actions, handlerBase = handler.replace(/Action$/, "");
log.debug(`[router] Resource: /${name} \u2192 ${handler} [${activeActions.join(", ")}]`);
const registerResourceRoutes = () => {
for (const action of activeActions)
switch (action) {
case "index":
stacksRouter.get(`/${name}`, `${handlerBase}IndexAction`);
break;
case "store":
stacksRouter.post(`/${name}`, `${handlerBase}StoreAction`);
break;
case "show":
stacksRouter.get(`/${name}/:id`, `${handlerBase}ShowAction`);
break;
case "update":
stacksRouter.put(`/${name}/:id`, `${handlerBase}UpdateAction`);
break;
case "destroy":
stacksRouter.delete(`/${name}/:id`, `${handlerBase}DestroyAction`);
break;
}
};
if (options?.middleware)
stacksRouter.group({ middleware: options.middleware }, registerResourceRoutes);
else
registerResourceRoutes();
return stacksRouter;
},
match(methods, path, handler) {
log.debug(`[router] Match: [${methods.join(", ")}] ${path} \u2192 ${typeof handler === "string" ? handler : "function"}`);
for (const method of methods) {
const m = method.toUpperCase(), { fullPath, routeKey } = registerRoute(m, path, handler), wrappedHandler = createMiddlewareHandler(routeKey, handler);
switch (m) {
case "GET":
bunRouter.get(fullPath, wrappedHandler);
break;
case "POST":
bunRouter.post(fullPath, wrappedHandler);
break;
case "PUT":
bunRouter.put(fullPath, wrappedHandler);
break;
case "PATCH":
bunRouter.patch(fullPath, wrappedHandler);
break;
case "DELETE":
bunRouter.delete(fullPath, wrappedHandler);
break;
case "OPTIONS":
bunRouter.options(fullPath, wrappedHandler);
break;
}
}
return createChainableRoute(`${methods[0]}:${currentPrefix}${path}`);
},
health() {
bunRouter.get("/api/health", async () => {
const checks = {}, probe = async (name, fn) => {
const start = Date.now();
try {
const ac = new AbortController, t = setTimeout(() => ac.abort(), 1500);
await Promise.race([
fn(),
new Promise((_, rej) => ac.signal.addEventListener("abort", () => rej(Error("timeout"))))
]);
clearTimeout(t);
checks[name] = { ok: !0, ms: Date.now() - start };
} catch (err) {
checks[name] = {
ok: !1,
ms: Date.n