UNPKG

@stacksjs/router

Version:
1,234 lines (1,233 loc) 57.7 kB
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