import type { Server, ServerWebSocket } from 'bun'; import { checkEnvironment } from './cli/checkEnvironment'; import { existsSync, rmSync, mkdirSync } from 'fs'; import path from 'node:path'; import { ComponentRegistry, formatCompileErrors } from './compiler/ComponentRegistry'; import type { RenderResult } from './compiler/ComponentRegistry'; import { loadSvelteConfig } from './compiler/svelteConfig'; import { buildInlineWebComponent } from './compiler/buildInlineWebComponent'; import { buildClientStatsRoutes, CLIENT_STATS_COMPONENT } from './dev/clientStatsRoutes'; import { buildEmailViewerRoutes, EMAIL_VIEWER_COMPONENT } from './dev/emailViewerRoutes'; import { isMochiPage, isMochiApi, isMochiWs, isMochiSse, isMochiFile, isMochiQueue, isServerPropsResolver, isAlsoHydrateMode, ALSO_HYDRATE_ENVELOPE_KEY } from './types'; import { HYDRATABLE_CONTEXT_KEY } from './islands/isHydratable'; import type { BunRouteValue, HttpMethod, MochiApiConfig, MochiApiHandler, MochiFileConfig, MochiFileResolver, MochiPageConfig, MochiPageHandlerConfig, MochiFormActionResult, MochiFormActions, MochiRouteValue, MochiServerPropsResolver, MochiServeOptions, MochiQueueConfig, RouteRegistrationResult, MochiSseConfig, MochiSseHandler, MochiSseStream, MochiWsConfig, MochiWsHandlers, MochiWsData, } from './types'; import { isFormFail, isFormRedirect, isFormSuccess } from './runtime/forms'; import { isEnhanceRequest, jsonError, jsonFailure, jsonRedirect, jsonSuccess } from './runtime/formsJson'; import { csrfCheck, DEFAULT_FORM_CONTENT_TYPES, DEFAULT_PROTECTED_METHODS } from './runtime/csrf'; import { applyFilter, initExtensions, runHook } from './extensions'; import { escapeHtmlAttr } from './utils/htmlEscape'; import { buildPublicUrl } from './runtime/proxy'; import { realpath } from 'node:fs/promises'; import { apiError, collectHeaderPairs, cssLinkTag, headResponse, isHtmlResponse, MochiHttpError, relForDisplay, toPosixPath, withHead } from './utils'; import type { MochiEvent, MochiEventKind, MochiResolveOptions } from './runtime/hooks'; import { applyResolveOptions } from './runtime/hooks'; import { alternateSlashPattern, trailingSlashRedirect } from './runtime/trailingSlash'; import { resolveWarmupEnabled, markWarmupRequest, isWarmablePattern } from './runtime/warmup'; import { createErrorResponder, DEFAULT_ERROR_PAGE_PATH } from './runtime/errors'; import { requestContext } from './runtime/requestContext'; import type { MochiRequestContext } from './runtime/requestContext'; import { createQueue, getQueue, closeAllQueueResources, runQueueRecovery } from './queue'; import { resetStartupMilestones } from './lifecycle'; import type { MochiQueue, MochiQueueOptions, MochiQueueListeners, MochiProcessor } from './queue'; import { finalizeCookieHeaders } from './runtime/cookies'; import { makeRequestContextBuilder } from './runtime/requestSetup'; import { createRouteLimiter, applyRateLimitHeaders } from './runtime/rateLimit'; import type { MochiRateLimitOptions, MochiRateLimitStore, RouteLimiter } from './runtime/rateLimit'; import { decryptProps } from './islands/serverIslandCrypto'; import { createImageHandler } from './image/imageEndpoint'; import { createLocalAssetHandler } from './image/localAssetRegistry'; import { getImageRuntime } from './image/config'; import { startImageCacheSweeper } from './image/sweeper'; import { getEmailRuntime, closeEmailTransport } from './email/config'; import { onDevEmailRecorded } from './email/devOutbox'; import { sendEmail } from './email/mailer'; import type { MochiEmailMessage, MochiEmailResult } from './email/types'; import { initMochiConfig } from './mochiConfig'; import { logger, setLogLevel, DEFAULT_LOG_LEVEL, type LogLevel } from './utils/log'; import { mochiEvents } from './events'; import type { MochiActionResult, MochiErrorEvent, MochiErrorKind, MochiServerStartEvent, MochiServerStopEvent } from './events'; import type { DebugBarData, DebugBarRuntimeData } from './runtime/requestContext'; import { consoleLogger } from './dev/consoleLogger'; import { parse as devalueParse, stringify as devalueStringify } from 'devalue'; import { ISLAND_FAILURE_CSS, ISLAND_FAILURE_DEV_CSS, islandFailureStub } from './web-components/islandFailureStub'; import { resolvePublicFiles, registerPublicRoutes, isExcludedDotPath } from './runtime/publicDir'; import { startDevWatcher } from './dev/devWatcher'; import { buildPageCacheAdminRoutes, PAGE_CACHE_ADMIN_COMPONENT } from './dev/pageCacheAdminRoutes'; import { liveReloadGreeting } from './dev/liveReloadGeneration'; const DEFAULT_HTML_SHELL = await Bun.file(new URL('./templates/default-shell.html', import.meta.url)).text(); let mochiVersionPromise: Promise | undefined; function readMochiVersion(): Promise { return (mochiVersionPromise ??= Bun.file(path.join(import.meta.dir, '..', 'package.json')) .json() .then((pkg) => (pkg as { version: string }).version) .catch(() => null)); } type ShellSlot = 'head' | 'css' | 'body' | 'script'; type ShellPart = { text: string } | { slot: ShellSlot }; // Parsing once per template turns per-request filling into a walk over these parts instead of a global-regex scan. // Splitting the template rather than the assembled output also keeps an injected body containing a literal // `{{mochi.script}}` from being re-expanded. function parseShellTemplate(template: string): ShellPart[] { const parts: ShellPart[] = []; const re = /\{\{mochi\.(head|css|body|script)\}\}/g; let last = 0; let m: RegExpExecArray | null; while ((m = re.exec(template)) !== null) { if (m.index > last) { parts.push({ text: template.slice(last, m.index) }); } parts.push({ slot: m[1] as ShellSlot }); last = m.index + m[0].length; } if (last < template.length) { parts.push({ text: template.slice(last) }); } return parts; } // Escaping `<` keeps a `` inside the payload from closing the tag early. function jsonForHtml(value: unknown): string { return JSON.stringify(value).replace(/ { if (!development) { return response; } if (response.headers.get('Content-Encoding')) { return response; } if (!ctx.debugBarData || !isHtmlResponse(response)) { return response; } const dynamic: Pick = { headers: collectHeaderPairs(response.headers), requestCookies: ctx.cookies.peekAll().map(({ name, value }) => [name, value]), }; const body = await response.text(); const tail = ``; return new Response(body + tail, { status: response.status, statusText: response.statusText, headers: response.headers, }); } export class Mochi { static page( componentPath: string, config?: { serverProps?: Record | MochiServerPropsResolver; actions?: MochiFormActions; rateLimit?: MochiRateLimitOptions | false; }, ): MochiPageConfig { return { __mochiPage: true, componentPath, serverProps: config?.serverProps, actions: config?.actions, rateLimit: config?.rateLimit, }; } static api(handler: MochiApiHandler, config?: { rateLimit?: MochiRateLimitOptions | false }): MochiApiConfig { return { __mochiApi: true, handler, rateLimit: config?.rateLimit }; } static ws(handlers: MochiWsHandlers): MochiWsConfig { return { __mochiWs: true, handlers: handlers as MochiWsHandlers, }; } static sse(handler: MochiSseHandler): MochiSseConfig { return { __mochiSse: true, handler }; } static file(source: string | MochiFileResolver): MochiFileConfig { return { __mochiFile: true, source }; } /** * Declare a background job queue. Like `page`/`api`/`ws`/`sse`/`file` this returns an inert config; the live producer * and consumer are created only once `Mochi.serve({ queues })` mounts the descriptor under its queue name. Add jobs from * anywhere via `Mochi.getQueue(name).add(...)`, and the queue drains gracefully on shutdown. */ static queue(config: MochiQueueOptions): MochiQueueConfig { // Whatever survives the destructure is forwarded verbatim to bunqueue. const { process, on, recover, ...options } = config; return { __mochiQueue: true, process: process as MochiProcessor, options, on: on as Partial> | undefined, recover: recover as ((queue: MochiQueue) => void | Promise) | undefined, }; } /** * Resolve the handle for a queue declared in `Mochi.serve({ queues })` so jobs can be `.add()`ed to it, passing the * payload type explicitly (`Mochi.getQueue(name)`). Throws for an undeclared name, or before `Mochi.serve()` mounts its queues. */ static getQueue(name: string): MochiQueue { return getQueue(name); } /** * Send a transactional email, configured under `Mochi.serve({ email })`. The body is `html`, `text`, or a Svelte * `component` rendered to HTML with its scoped CSS inlined. Callable from any server-side code — route actions, * API handlers, or queue jobs. */ static email(message: MochiEmailMessage): Promise { return sendEmail(message); } /** * Computes every request-invariant shell fragment (log shim, warn shim, island `\n`; const serverIslandScript = ``; const liveReloadTail = liveReloadClientJs ? `` : ''; const toolbarDiv = registry.debugBarEnabled ? '
' : ''; const assetPrefixJson = JSON.stringify(registry.assetPrefix); // Parse the shell once; re-parse only when a dev shell edit swaps the template. let parsedFrom: string | undefined; let parts: ShellPart[] = []; return (result, opts) => { const template = getTemplate(); if (template !== parsedFrom) { parts = parseShellTemplate(template); parsedFrom = template; } const bootstrapUrl = result.bootstrapUrl; const cssLinks = result.cssUrls.map(cssLinkTag).join('\n'); const debugBarUrl = registry.getDebugBarUrl(); const debugInfoScript = registry.debugBarEnabled && opts?.debugInfo ? `` : ''; const pageEntryScript = liveReloadClientJs && opts?.pageEntry ? `` : ''; const head = logLevelScript + warnShim + result.head; const css = cssStylePrefix + cssLinks; const body = result.body + debugInfoScript + pageEntryScript + toolbarDiv; const script = (bootstrapUrl ? `` : '') + (result.hasServerIslands ? serverIslandScript : '') + (debugBarUrl ? `` : '') + liveReloadTail; let out = ''; for (const part of parts) { if ('text' in part) { out += part.text; } else { out += part.slot === 'head' ? head : part.slot === 'css' ? css : part.slot === 'body' ? body : script; } } return out; }; } static async serve(options: MochiServeOptions): Promise> { const { svelteVersion } = await checkEnvironment(); const mochiVersion = await readMochiVersion(); initExtensions(options); await runHook('mochi:init', { options }); await initMochiConfig(options); // Resolved once at startup and captured by the per-request closures below. Each default Set is copied before it // reaches the user, so an in-place mutation can't poison the framework default for the next call. const formContentTypes: ReadonlySet = applyFilter('csrf:formContentTypes', new Set(DEFAULT_FORM_CONTENT_TYPES), { options }); const protectedMethods: ReadonlySet = applyFilter('csrf:protectedMethods', new Set(DEFAULT_PROTECTED_METHODS), { options }); const trustedOrigins: ReadonlySet = applyFilter('csrf:trustedOrigins', new Set(options.csrf?.trustedOrigins ?? []), { options }); const cookieDefaults = applyFilter('cookie:defaults', {}, { options }); const development = options.development ?? true; const warmupEnabled = resolveWarmupEnabled(options.warmup, development); const debugBarEnabled = development && (options.debugBar ?? true); const liveReloadEnabled = options.liveReload ?? development; const middleware = options.handle; const baseOutDir = options.outDir ?? './.mochi'; // Nesting dev artifacts keeps a stale prod manifest and dev chunks apart across a later `start`, while prod stays at // the root so Docker and deploys are unaffected. const outDir = development ? path.join(baseOutDir, 'dev') : baseOutDir; const publicDir = options.publicDir ?? './public'; // Only a file-based shell can be watched/re-read; an inline-string shell // has no source file, and the built-in default is bundled, not a runtime file. const shellPath = options.htmlShell?.endsWith('.html') ? path.resolve(options.htmlShell) : undefined; const watchPaths = Array.from(new Set(['src', 'public', ...(shellPath ? [shellPath] : []), ...(options.additionalWatchPaths ?? [])])); const emitError = (kind: MochiErrorKind, requestId: string, req: Request, url: URL, status: number, err: unknown, actionName?: string): void => { const message = err instanceof Error ? err.message : err == null ? 'Unknown error' : String(err); const payload: MochiErrorEvent = { requestId, kind, path: url.pathname + url.search, method: req.method, status, message, }; if (development && err instanceof Error && typeof err.stack === 'string') { payload.stack = err.stack; } if (actionName !== undefined) { payload.actionName = actionName; } mochiEvents.emit('error', payload); }; const requestIdHeader = options.proxy?.requestIdHeader; const newRequestId = (req: Request): string => { if (requestIdHeader) { const inbound = req.headers.get(requestIdHeader)?.trim(); if (inbound) { return inbound; } } return Bun.randomUUIDv7(); }; const { enabled: loggerEnabled = true, level: configuredLevel, ...loggerOptions } = options.logger ?? {}; const resolvedLogLevel: LogLevel = configuredLevel ?? (development ? 'info' : DEFAULT_LOG_LEVEL); setLogLevel(resolvedLogLevel); if (loggerEnabled) { consoleLogger(loggerOptions); } logger.info(`Starting in ${development ? 'development' : 'production'} mode`); // In production, load prebuilt assets from manifest if available const manifestPath = options.manifest ?? `${outDir}/manifest.json`; let registry: ComponentRegistry; if (!development && existsSync(manifestPath)) { logger.info(`Loading prebuilt manifest from ${manifestPath}`); // The registry takes its outDir from the manifest's own directory, so an explicit `manifest` pointing elsewhere // relocates on-demand island compiles along with it. registry = await ComponentRegistry.fromManifest(manifestPath, development); if (options.assetPrefix !== undefined && options.assetPrefix !== registry.assetPrefix) { logger.warn( `assetPrefix in Mochi.serve() (${JSON.stringify(options.assetPrefix)}) differs from the manifest (${JSON.stringify(registry.assetPrefix)}). Using the manifest value — URLs are baked in at build time.`, ); } } else { const svelteConfig = await loadSvelteConfig(options.svelteConfigPath); registry = new ComponentRegistry({ development, debugBar: options.debugBar, outDir, assetPrefix: options.assetPrefix, svelteConfig, svelteCompiler: options.svelteCompiler, markdown: options.markdown, optimize: options.optimize, barrelWarnings: options.barrelWarnings, }); // No-op in dev or when the option is off; production-without-manifest // compiles at startup, so the shake must run before the first compile. await registry.prepareShake(); if (development) { // outDir is the dev-only dir (.mochi/dev). Wipe it whole each startup so // stale entry-hmr / import-css / compiled chunks can't leak across restarts. rmSync(outDir, { recursive: true, force: true }); for (const sub of ['svelte-client', 'svelte-compile', 'svelte-css']) { mkdirSync(path.join(outDir, sub), { recursive: true }); } } else { // Production without a prebuilt manifest is valid but much slower, compiling components at boot and server islands // on the request path; the error level keeps a forgotten build from masquerading as a healthy deploy. logger.error( `Running in production without a prebuilt manifest (${manifestPath} not found). ` + `This is an unsupported configuration and is not recommended: components compile at startup ` + `and server islands compile on the first request, making cold starts and initial responses ` + `much slower. Run \`mochi-framework build\` before \`start\` to precompile and bake the manifest.`, ); } } const emailTransportType = getEmailRuntime().options.transport.type; // The dev outbox captures mail off the resolved transport alone, so the viewer route must key off the same condition; // keying it off the debug bar would let `debugBar: false` capture mail with no way to read it back. const emailViewerEnabled = development && emailTransportType === 'dev'; const serverDebugInfo: Partial = { mochiVersion: mochiVersion ?? undefined, svelteVersion, bunVersion: Bun.version, config: { mode: development ? 'development' : 'production', port: options.port, hostname: options.hostname, debugBar: debugBarEnabled, liveReload: liveReloadEnabled, warmup: warmupEnabled, compressServerIslandProps: options.compressServerIslandProps ?? false, trailingSlash: options.trailingSlash ?? 'never', assetPrefix: registry.assetPrefix || undefined, logLevel: resolvedLogLevel, middleware: !!middleware, csrf: !!options.csrf, proxy: !!options.proxy, markdown: !!options.markdown, email: emailTransportType, routeCount: Object.keys(options.routes ?? {}).length, }, }; let shellTemplate: string; if (options.htmlShell) { shellTemplate = shellPath ? await Bun.file(shellPath).text() : options.htmlShell; } else { shellTemplate = DEFAULT_HTML_SHELL; } shellTemplate = applyFilter('html:shell', shellTemplate, { options, development }); // Both render closures below capture `shellTemplate` by reference, so reassigning it lands on the next request. const reloadShell = shellPath ? async () => { shellTemplate = applyFilter('html:shell', await Bun.file(shellPath).text(), { options, development }); } : undefined; const errorPagePath = options.errorPage ?? DEFAULT_ERROR_PAGE_PATH; // Compiling every page entrypoint in one `Bun.build` below lets splitting pull shared transitive deps (devalue, // mochi-framework internals) into chunk files instead of inlining them per page. const ssrEntrypoints: string[] = [errorPagePath, CLIENT_STATS_COMPONENT]; if (debugBarEnabled) { ssrEntrypoints.push(PAGE_CACHE_ADMIN_COMPONENT); } if (emailViewerEnabled) { ssrEntrypoints.push(EMAIL_VIEWER_COMPONENT); } if (options.routes) { for (const handler of Object.values(options.routes)) { if (isMochiPage(handler)) { if (existsSync(handler.componentPath)) { ssrEntrypoints.push(handler.componentPath); } else if (development) { logger.warn(`Route component not found: ${handler.componentPath} — will compile when created`); } } } } await registry.compileAll(ssrEntrypoints); // Prod-with-manifest restores this from disk (baked by `build()`); otherwise // build it on demand. LiveReload is dev-only, so it's never prebuilt. const serverIslandClientJs = registry.serverIslandClientJs ?? (await buildInlineWebComponent('./web-components/ServerIsland.ts')); const liveReloadClientJs = liveReloadEnabled ? await buildInlineWebComponent('./web-components/LiveReload.ts') : ''; // Precompute request-invariant shell fragments once; `getTemplate` reads the // live `shellTemplate` so dev shell edits (reloadShell) are picked up. const renderShell = Mochi.createShellRenderer(registry, { serverIslandClientJs, liveReloadClientJs, logLevel: resolvedLogLevel, getTemplate: () => shellTemplate, }); const { renderErrorResponse, routeErrorResponse } = createErrorResponder({ handleError: options.handleError, development, registry, errorPagePath, renderShell: (result) => renderShell(result), }); // Mirrors the handleError logic in renderErrorResponse, skipping the HTML render for the enhanced JSON path. const handleEnhancedError = async (err: unknown, event: MochiEvent): Promise => { let status = err instanceof MochiHttpError ? err.status : 500; let message = err instanceof Error ? err.message : 'Internal Error'; if (options.handleError) { try { const override = await options.handleError({ error: err, event, status, message }); if (override instanceof Response) { return override; } if (override && typeof override === 'object' && typeof (override as { status?: unknown }).status === 'number') { status = (override as { status: number }).status; message = (override as { message: string }).message; } } catch (hookErr) { logger.error('handleError hook threw:', hookErr); } } return jsonError(status, message); }; // Pre-compile Mochi.page() handlers so SSR is ready at startup const mochiPageMap = new Map(); const warmupHandlers: { pattern: string; handler: (req: Request, server: Server) => Promise }[] = []; const wsHandlersMap = new Map>(); const apiHandlerMap = development ? new Map() : undefined; const sseHandlerMap = development ? new Map() : undefined; const pageConfigMap = development ? new Map() : undefined; const bunRoutes: Record = {}; const routeCounts = { page: 0, api: 0, ws: 0, sse: 0, file: 0 }; const trailingSlashPolicy = options.trailingSlash; const buildRequestContext = makeRequestContextBuilder({ proxy: options.proxy, csrf: options.csrf, trailingSlashPolicy, cookieDefaults, development, debugBarEnabled, formContentTypes, protectedMethods, trustedOrigins, newRequestId, }); const internalRoutes: Record = { // Gated behind the debug bar, like the page-cache admin routes, since the stats page discloses every bundle's input // file paths and sizes — project structure and dependency names. ...(debugBarEnabled ? buildClientStatsRoutes(registry) : {}), ...(debugBarEnabled ? buildPageCacheAdminRoutes() : {}), ...(emailViewerEnabled ? buildEmailViewerRoutes(registry) : {}), }; const allRoutes = Object.keys(internalRoutes).length > 0 ? { ...internalRoutes, ...(options.routes ?? {}) } : options.routes; const rateLimitStores = new Set(); // Route closures look their limiter up per request so the dev watcher can swap one in place when a route's // `rateLimit` config changes; capturing it in a const would pin the boot-time config until restart. A null entry // marks a limitable route with no limiter. const routeLimiters = new Map(); let sharedGlobalLimiter: RouteLimiter | null = null; function buildLimiter(routeCfg: MochiRateLimitOptions | false | undefined, pattern: string): RouteLimiter | null { if (routeCfg === false) { return null; } if (routeCfg) { // A route's own config is auto-namespaced by its pattern, keeping two routes on a shared persisted store off one // bucket. The shared global limiter below passes no pattern, so its routes stay on a common bucket. const limiter = createRouteLimiter(routeCfg, pattern); if (limiter.ownsStore) { rateLimitStores.add(limiter.store); } return limiter; } if (!options.rateLimit) { return null; } // Internal routes (debug bar, email viewer) never inherit the global // limiter — dev tooling polling must not drain the user-facing quota. if (pattern in internalRoutes) { return null; } if (!sharedGlobalLimiter) { sharedGlobalLimiter = createRouteLimiter(options.rateLimit); if (sharedGlobalLimiter.ownsStore) { rateLimitStores.add(sharedGlobalLimiter.store); } } return sharedGlobalLimiter; } function resolveLimiter(routeCfg: MochiRateLimitOptions | false | undefined, pattern: string): void { routeLimiters.set(pattern, buildLimiter(routeCfg, pattern)); } function retireLimiter(pattern: string): void { const limiter = routeLimiters.get(pattern); routeLimiters.delete(pattern); // The shared global limiter is never shut down here — other routes still // use it, and its counters intentionally survive dev reloads. if (limiter && limiter !== sharedGlobalLimiter && limiter.ownsStore) { rateLimitStores.delete(limiter.store); // sqliteStore's shutdown can throw synchronously from its finalize-verification guard, which a bare // `Promise.resolve()` would let escape into the dev watcher. (async () => limiter.store.shutdown?.())().catch((err: unknown) => { logger.warn(`Rate limit store shutdown failed: ${err instanceof Error ? err.message : err}`); }); } } // Dev-watcher hook for in-place route updates (same pattern, same type): // rebuild the limiter so `rateLimit` edits take effect without a restart. function updateRouteLimiter(pattern: string, routeCfg: MochiRateLimitOptions | false | undefined): void { if (!routeLimiters.has(pattern)) { return; } retireLimiter(pattern); resolveLimiter(routeCfg, pattern); } interface RouteLimitGate { headers?: Record; blockedBody?: Record; blockedMessage?: string; } async function checkRouteLimit(limiter: RouteLimiter | null, ctx: MochiRequestContext, req: Request): Promise { if (!limiter || ctx.isWarmup) { return {}; } const outcome = await limiter.check(req, ctx.getClientAddress); if (outcome.kind === 'skip') { return {}; } if (outcome.kind === 'allowed') { ctx.rateLimit = outcome.info; return { headers: outcome.headers }; } // info is null only when the store failed and onStoreError said 'deny' — // say so instead of blaming the client's traffic. const message = outcome.info === null ? 'Rate limiting unavailable.' : outcome.retryAfterSeconds != null ? `Rate limit exceeded. Try again in ${outcome.retryAfterSeconds}s.` : 'Rate limit exceeded.'; return { headers: outcome.headers, blockedBody: outcome.body, blockedMessage: message }; } async function registerRoutePattern(pattern: string, handler: MochiRouteValue): Promise { if (isMochiPage(handler)) { mochiPageMap.set(pattern, handler); const { componentPath, serverProps, actions } = handler; resolveLimiter(handler.rateLimit, pattern); if (pageConfigMap) { pageConfigMap.set(pattern, { serverProps, actions }); } if (existsSync(componentPath)) { await registry.compile(componentPath); } const renderComponent = async (req: Request, ctx: MochiRequestContext, resolveOpts: MochiResolveOptions | undefined, statusOverride?: number): Promise => { const compileErrors = registry.getErrors(); if (compileErrors.length > 0) { throw new MochiHttpError(500, formatCompileErrors(compileErrors)); } const liveServerProps = pageConfigMap ? pageConfigMap.get(pattern)?.serverProps : serverProps; const baseProps = isServerPropsResolver(liveServerProps) ? ((await liveServerProps(req, ctx.params)) ?? {}) : (liveServerProps ?? {}); const liveActions = pageConfigMap ? pageConfigMap.get(pattern)?.actions : actions; if (liveActions && 'form' in baseProps) { throw new Error( `[mochi] Route "${pattern}" has form actions and also returns a prop named "form". ` + `"form" is reserved for the form action result — rename your prop.`, ); } const formProp = ctx.form ?? null; const resolvedProps = formProp === null ? baseProps : { ...baseProps, form: formProp }; const ssrStart = performance.now(); const result = await registry.renderComponent(componentPath, resolvedProps); if (result.debugBarData) { result.debugBarData.ssrDurationMs = Math.round((performance.now() - ssrStart) * 100) / 100; if (ctx.requestCache) { const { hits, misses, map, perKey } = ctx.requestCache; result.debugBarData.requestCache = { hits, misses, entries: map.size, keys: [...perKey].map(([key, t]) => ({ key, hits: t.hits, misses: t.misses })), }; } if (result.hasServerIslands) { const serverIslandSize = new TextEncoder().encode(serverIslandClientJs).length; (result.debugBarData.bundles ??= []).push({ url: '(inline)', label: 'Server island runtime', sizeBytes: serverIslandSize, kind: 'bootstrap', inputs: [], }); } } const html = renderShell(result, { debugInfo: result.debugBarData ? { ...result.debugBarData, liveReloadEnabled, ...serverDebugInfo } : undefined, pageEntry: liveReloadEnabled ? path.resolve(componentPath) : undefined, }); const response = new Response(html, { status: statusOverride, headers: { 'Content-Type': 'text/html; charset=utf-8' }, }); return applyResolveOptions(response, resolveOpts); }; const wrapRequest = async ( req: Request, server: Server, inner: (ctx: MochiRequestContext, event: MochiEvent, resolveOpts: MochiResolveOptions | undefined) => Promise, ): Promise => { const setup = buildRequestContext(req, server, { kind: 'page', pattern, csrfErrorTransform: (resp) => (isEnhanceRequest(req) ? jsonError(resp.status, 'Cross-site form submission forbidden') : resp), }); if ('earlyResponse' in setup) { return setup.earlyResponse; } const { ctx, start, requestId, url, params } = setup; const event: MochiEvent = { request: req, url, server, locals: ctx.locals, kind: 'page', isWarmup: ctx.isWarmup }; return requestContext.run(ctx, async () => { runHook('route:matched', { pattern, request: req, url, params, kind: 'page' }); const innerResolve = async (_event: MochiEvent, resolveOpts?: MochiResolveOptions): Promise => inner(ctx, event, resolveOpts); const gate = await checkRouteLimit(routeLimiters.get(pattern) ?? null, ctx, req); let blockedResponse: Response | undefined; if (gate.blockedMessage) { // Enhanced form POSTs expect JSON (mirrors csrfErrorTransform above); // everything else gets the configured error page at 429. blockedResponse = isEnhanceRequest(req) ? jsonError(429, gate.blockedMessage) : await routeErrorResponse(req, event, undefined, new MochiHttpError(429, gate.blockedMessage)); } const response = blockedResponse ?? (middleware ? await middleware({ event, resolve: innerResolve }) : await innerResolve(event)); let final = finalizeCookieHeaders(response, ctx.cookies); if (gate.headers) { final = applyRateLimitHeaders(final, gate.headers); } const shipped = await appendDebugTail(final, ctx, development); mochiEvents.emit('request', { requestId, kind: 'page', method: req.method, path: url.pathname + url.search, status: shipped.status, duration: performance.now() - start, ...(ctx.isWarmup ? { warmup: true } : {}), }); return shipped; }); }; const getHandler = (req: Request, server: Server): Promise => wrapRequest(req, server, async (ctx, event, resolveOpts) => { try { return await renderComponent(req, ctx, resolveOpts); } catch (err) { const response = await routeErrorResponse(req, event, resolveOpts, err); emitError('page', ctx.requestId, req, ctx.url, response.status, err); return response; } }); if (warmupEnabled && isWarmablePattern(pattern)) { warmupHandlers.push({ pattern, handler: getHandler }); } if (actions || pageConfigMap) { const postHandler = (req: Request, server: Server): Promise => wrapRequest(req, server, async (ctx, event, resolveOpts) => { const path = ctx.url.pathname + ctx.url.search; const enhanced = isEnhanceRequest(req); const emitActionComplete = (actionName: string, result: MochiActionResult, status?: number): void => { const payload: { requestId: string; path: string; actionName: string; result: MochiActionResult; status?: number; } = { requestId: ctx.requestId, path, actionName, result }; if (status !== undefined) { payload.status = status; } mochiEvents.emit('action:complete', payload); }; const livePostActions = pageConfigMap ? pageConfigMap.get(pattern)?.actions : actions; if (!livePostActions) { return new Response('Method Not Allowed', { status: 405 }); } let actionName = 'default'; for (const key of ctx.url.searchParams.keys()) { if (key.startsWith('/')) { actionName = key.slice(1); break; } } const actionHandler = livePostActions[actionName]; if (!actionHandler) { const unknownErr = new Error(`Unknown form action: ${actionName}`); const response = enhanced ? jsonError(404, `Unknown form action: ${actionName}`) : await renderErrorResponse({ req, event, resolveOpts, status: 404, message: `Unknown form action: ${actionName}`, thrown: null, }); emitError('action', ctx.requestId, req, ctx.url, response.status, unknownErr, actionName); emitActionComplete(actionName, 'error', response.status); return response; } let formData: FormData; try { formData = await req.formData(); } catch (err) { const response = enhanced ? jsonError(400, 'Invalid form body') : await renderErrorResponse({ req, event, resolveOpts, status: 400, message: 'Invalid form body', thrown: err, }); emitError('action', ctx.requestId, req, ctx.url, response.status, err, actionName); emitActionComplete(actionName, 'error', response.status); return response; } mochiEvents.emit('action:invoke', { requestId: ctx.requestId, path, actionName, }); let result: MochiFormActionResult; try { result = await actionHandler({ request: req, url: ctx.url, server, locals: ctx.locals, kind: 'page', isWarmup: ctx.isWarmup, method: 'POST' as HttpMethod, formData, actionName, cookies: ctx.cookies, params: ctx.params, }); } catch (err) { if (enhanced) { const response = await handleEnhancedError(err, event); emitError('action', ctx.requestId, req, ctx.url, response.status, err, actionName); emitActionComplete(actionName, 'error', response.status); return response; } const response = await routeErrorResponse(req, event, resolveOpts, err); emitError('action', ctx.requestId, req, ctx.url, response.status, err, actionName); emitActionComplete(actionName, 'error', response.status); return response; } if (result instanceof Response) { emitActionComplete(actionName, 'success', result.status); return applyResolveOptions(result, resolveOpts); } if (isFormRedirect(result)) { emitActionComplete(actionName, 'redirect', result.status); if (enhanced) { return jsonRedirect(result.status, result.location); } const redirectResponse = new Response(null, { status: result.status, headers: { Location: result.location }, }); return applyResolveOptions(redirectResponse, resolveOpts); } try { if (isFormFail(result)) { if (enhanced) { emitActionComplete(actionName, 'fail', result.status); return jsonFailure(result.status, result.data); } ctx.form = { ok: false, action: actionName, status: result.status, data: result.data, }; emitActionComplete(actionName, 'fail', result.status); return await renderComponent(req, ctx, resolveOpts, result.status); } if (enhanced) { emitActionComplete(actionName, 'success'); if (result === undefined || result === null) { return jsonSuccess(undefined, { emptyResult: true }); } return jsonSuccess(isFormSuccess(result) ? result.data : {}); } const data = isFormSuccess(result) ? result.data : {}; ctx.form = { ok: true, action: actionName, data }; emitActionComplete(actionName, 'success'); return await renderComponent(req, ctx, resolveOpts); } catch (err) { if (enhanced) { const response = await handleEnhancedError(err, event); emitError('action', ctx.requestId, req, ctx.url, response.status, err, actionName); return response; } const response = await routeErrorResponse(req, event, resolveOpts, err); emitError('action', ctx.requestId, req, ctx.url, response.status, err, actionName); return response; } }); return { bunRouteValue: withHead({ GET: getHandler, POST: postHandler, } as unknown as BunRouteValue), type: 'page', }; } // A method-keyed object makes Bun 405 a POST/PUT to an action-less page; a bare function runs for every method // and would render 200 on POST in production, diverging from dev, where `pageConfigMap` forces this path anyway. return { bunRouteValue: withHead({ GET: getHandler } as unknown as BunRouteValue), type: 'page' }; } else if (isMochiApi(handler)) { if (apiHandlerMap) { apiHandlerMap.set(pattern, handler.handler); } const capturedApiHandler = handler.handler; resolveLimiter(handler.rateLimit, pattern); const bunRouteValue: BunRouteValue = async (req: Request, server: Server): Promise => { const setup = buildRequestContext(req, server, { kind: 'api', pattern }); if ('earlyResponse' in setup) { return setup.earlyResponse; } const { ctx, start, requestId, url, params } = setup; return requestContext.run(ctx, async () => { runHook('route:matched', { pattern, request: req, url, params, kind: 'api' }); const event: MochiEvent = { request: req, url, server, locals: ctx.locals, kind: 'api', isWarmup: ctx.isWarmup }; const gate = await checkRouteLimit(routeLimiters.get(pattern) ?? null, ctx, req); let blockedResponse: Response | undefined; if (gate.blockedBody) { blockedResponse = Response.json(gate.blockedBody, { status: 429 }); } const innerResolve = async (event: MochiEvent, resolveOpts?: MochiResolveOptions): Promise => { const apiEvent = { ...event, method: event.request.method as HttpMethod, params: ctx.params, cookies: ctx.cookies, }; try { const apiHandler = (apiHandlerMap ? apiHandlerMap.get(pattern) : undefined) ?? capturedApiHandler; const response = await apiHandler(apiEvent); return applyResolveOptions(response, resolveOpts); } catch (err) { if (err instanceof MochiHttpError) { logger.error(`${event.request.method} ${event.url.pathname} → ${err.status}: ${err.message}`); emitError('api', requestId, req, event.url, err.status, err); return apiError(err.status, err.message); } logger.error(`${event.request.method} ${event.url.pathname} → 500:`, err); emitError('api', requestId, req, event.url, 500, err); return apiError(500, 'Internal Server Error'); } }; const response = blockedResponse ?? (middleware ? await middleware({ event, resolve: innerResolve }) : await innerResolve(event)); let final = finalizeCookieHeaders(response, ctx.cookies); if (gate.headers) { final = applyRateLimitHeaders(final, gate.headers); } mochiEvents.emit('request', { requestId, kind: 'api', method: req.method, path: url.pathname + url.search, status: final.status, duration: performance.now() - start, }); return final; }); }; return { bunRouteValue: withHead(bunRouteValue), type: 'api' }; } else if (isMochiWs(handler)) { const wsHandlers = handler.handlers; wsHandlersMap.set(pattern, wsHandlers); const bunRouteValue = (async (req: Request, server: Server) => { const setup = buildRequestContext(req, server, { kind: 'ws', pattern }); if ('earlyResponse' in setup) { return setup.earlyResponse; } const { ctx: wsHookCtx, start, requestId: wsRequestId, url: wsUrl, params: wsParams } = setup; const wsPath = wsUrl.pathname + wsUrl.search; // A non-upgrade request (e.g. a HEAD probe or plain GET) never becomes // a socket, so it never emits `ws:open`. const emitWsReject = (status: number): void => { mochiEvents.emit('request', { requestId: wsRequestId, kind: 'error', method: req.method, path: wsPath, status, duration: performance.now() - start, }); }; requestContext.run(wsHookCtx, () => { runHook('route:matched', { pattern, request: req, url: wsUrl, params: wsParams, kind: 'ws', }); }); let userData: unknown = undefined; const liveWsHandlers = wsHandlersMap.get(pattern) ?? wsHandlers; if (liveWsHandlers.upgrade) { const result = await liveWsHandlers.upgrade(req, wsParams); if (result === false) { emitWsReject(400); return new Response('WebSocket upgrade rejected', { status: 400, }); } userData = result; } const success = ( server as unknown as { upgrade: (req: Request, opts: Record) => boolean; } ).upgrade(req, { data: { __mochiRoutePattern: pattern, __mochiOpenedAt: performance.now(), __mochiPath: wsPath, user: userData, } satisfies MochiWsData, }); if (!success) { emitWsReject(500); return new Response('WebSocket upgrade failed', { status: 500 }); } mochiEvents.emit('ws:open', { path: wsPath, duration: performance.now() - start, }); return undefined; }) as unknown as BunRouteValue; return { bunRouteValue, type: 'ws' }; } else if (isMochiSse(handler)) { if (sseHandlerMap) { sseHandlerMap.set(pattern, handler.handler); } const capturedSseHandler = handler.handler; const bunRouteValue: BunRouteValue = async (req: Request, server: Server): Promise => { const setup = buildRequestContext(req, server, { kind: 'sse', pattern }); if ('earlyResponse' in setup) { return setup.earlyResponse; } const { ctx: sseHookCtx, start: sseStart, requestId: sseRequestId, url, params: sseParams } = setup; const path = url.pathname + url.search; // SSE streams are GET-only. HEAD is not supported: answering it would // mean either opening a stream (defeats the point of a body-less probe) if (req.method === 'HEAD') { mochiEvents.emit('request', { requestId: sseRequestId, kind: 'error', method: req.method, path, status: 405, duration: performance.now() - sseStart, }); return new Response(null, { status: 405, headers: { Allow: 'GET' } }); } requestContext.run(sseHookCtx, () => { runHook('route:matched', { pattern, request: req, url, params: sseParams, kind: 'sse', }); }); let closed = false; let openedAt = 0; const emitClose = () => { if (closed) { return; } closed = true; mochiEvents.emit('sse:close', { path, duration: performance.now() - openedAt, }); }; let closeCallbacks: Array<() => void> = []; let controller: ReadableStreamDefaultController; const body = new ReadableStream({ start(ctrl) { controller = ctrl; openedAt = performance.now(); mochiEvents.emit('sse:open', { path }); const stream: MochiSseStream = { send(data, opts) { let frame = ''; if (opts?.id) { frame += `id: ${opts.id}\n`; } if (opts?.event) { frame += `event: ${opts.event}\n`; } for (const line of data.split('\n')) { frame += `data: ${line}\n`; } frame += '\n'; controller.enqueue(frame); mochiEvents.emit('sse:message', { path, size: Buffer.byteLength(data, 'utf8'), event: opts?.event, }); }, close() { controller.close(); emitClose(); }, onClose(cb) { closeCallbacks.push(cb); }, }; const liveSseHandler = (sseHandlerMap ? sseHandlerMap.get(pattern) : undefined) ?? capturedSseHandler; liveSseHandler(stream, req); }, cancel() { for (const cb of closeCallbacks) { cb(); } closeCallbacks = []; emitClose(); }, }); return new Response(body, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', }, }); }; return { bunRouteValue, type: 'sse' }; } else if (isMochiFile(handler)) { const source = handler.source; const bunRouteValue: BunRouteValue = async (req: Request, server: Server): Promise => { const setup = buildRequestContext(req, server, { kind: 'file', pattern }); if ('earlyResponse' in setup) { return setup.earlyResponse; } const { ctx, start, requestId, url, params } = setup; return requestContext.run(ctx, async () => { runHook('route:matched', { pattern, request: req, url, params, kind: 'file' }); const finish = (response: Response): Response => { const final = finalizeCookieHeaders(response, ctx.cookies); mochiEvents.emit('request', { requestId, kind: 'file', method: req.method, path: url.pathname + url.search, status: final.status, duration: performance.now() - start, }); return final; }; try { const filePath = typeof source === 'function' ? await source(req, ctx.params) : source; // Route params are URL-decoded and may contain `../`, so the resolved path is confined to the app root. // `realpath` resolves symlinks first, closing the in-root-symlink-points-out escape, and proves the file // exists (ENOENT → 404); containment goes through `path.relative`, which handles separators and canonical casing. const resolvedPath = path.resolve(filePath); let realPath: string; try { realPath = await realpath(resolvedPath); } catch { emitError('file', requestId, req, url, 404, new Error(`File not found: ${filePath}`)); return finish(new Response('Not Found', { status: 404, headers: { 'Content-Type': 'text/plain; charset=utf-8' } })); } const appRoot = process.cwd(); const rel = path.relative(appRoot, realPath); if (rel === '' || rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) { emitError('file', requestId, req, url, 404, new Error(`Path escapes the app root: ${filePath}`)); return finish(new Response('Not Found', { status: 404, headers: { 'Content-Type': 'text/plain; charset=utf-8' } })); } // The containment check above only stops escapes, so dotfiles and dot-directories inside the root // (`.env`, `.mochi/…`, `.git/…`) are rejected here. `.well-known` stays allowed, matching the public-dir policy. if (isExcludedDotPath(toPosixPath(rel))) { emitError('file', requestId, req, url, 404, new Error(`Refusing to serve dotfile path: ${filePath}`)); return finish(new Response('Not Found', { status: 404, headers: { 'Content-Type': 'text/plain; charset=utf-8' } })); } const file = Bun.file(realPath); // `realpath` resolves directories too, so this turns a directory target into a 404 rather than an EISDIR 500. if (!(await file.exists())) { emitError('file', requestId, req, url, 404, new Error(`File not found: ${filePath}`)); return finish(new Response('Not Found', { status: 404, headers: { 'Content-Type': 'text/plain; charset=utf-8' } })); } if (req.method === 'HEAD') { return finish(new Response(null, { status: 200, headers: { 'Content-Type': file.type || 'application/octet-stream', 'Content-Length': String(file.size) } })); } // new Response(Bun.file) sets Content-Type and Content-Length automatically. return finish(new Response(file)); } catch (err) { if (err instanceof MochiHttpError) { logger.error(`${req.method} ${url.pathname} → ${err.status}: ${err.message}`); emitError('file', requestId, req, url, err.status, err); return finish(new Response(err.message, { status: err.status, headers: { 'Content-Type': 'text/plain; charset=utf-8' } })); } logger.error(`${req.method} ${url.pathname} → 500:`, err); emitError('file', requestId, req, url, 500, err); return finish(new Response('Internal Server Error', { status: 500, headers: { 'Content-Type': 'text/plain; charset=utf-8' } })); } }); }; return { bunRouteValue, type: 'file' }; } return null; } function unregisterRoutePattern(pattern: string): void { mochiPageMap.delete(pattern); apiHandlerMap?.delete(pattern); sseHandlerMap?.delete(pattern); wsHandlersMap?.delete(pattern); pageConfigMap?.delete(pattern); // Dev re-registration creates a fresh limiter — shut down the outgoing // per-route store so its sweep timer / sqlite handle doesn't leak. retireLimiter(pattern); } if (allRoutes) { for (const [pattern, handler] of Object.entries(allRoutes)) { const result = await registerRoutePattern(pattern, handler); if (result) { bunRoutes[pattern] = result.bunRouteValue; routeCounts[result.type] += 1; } else { bunRoutes[pattern] = handler as BunRouteValue; } } } // Registering the alt-slash variant lets Bun's literal pattern matcher match both `/foo` and `/foo/`; the per-handler // redirect checks above then turn the non-canonical form into a 301/308. if (trailingSlashPolicy) { for (const [pattern, value] of Object.entries(bunRoutes)) { const alt = alternateSlashPattern(pattern); if (alt && !(alt in bunRoutes)) { bunRoutes[alt] = value; } } } // Register server island endpoint bunRoutes[`${registry.assetPrefix}/island/:componentName`] = withHead(async (req: Request, server: Server): Promise => { const setup = buildRequestContext(req, server, { kind: 'island', pattern: `${registry.assetPrefix}/island/:componentName`, paramsOverride: {}, }); if ('earlyResponse' in setup) { return setup.earlyResponse; } const { ctx, url, params } = setup; const componentName = params.componentName; if (!componentName) { return new Response('Missing component name', { status: 400 }); } const signedProps = url.searchParams.get('props') ?? ''; // Decrypt props (empty means no props) let decodedProps: Record; if (signedProps) { const propsJson = decryptProps(signedProps, componentName); if (propsJson === null) { return new Response('Invalid props', { status: 403 }); } decodedProps = devalueParse(propsJson) as Record; } else { decodedProps = {}; } // `islandId` and `__mochi_ah` ride inside the signed envelope as transport only, and are split into a fresh object // so neither reaches the component as a prop. The hydrate mode comes from the decrypted payload: trusting a // `?hydrate=` query param would let anyone append it to a sealed token and get the props echoed back in plaintext. const { [ALSO_HYDRATE_ENVELOPE_KEY]: rawHydrateMode, islandId: rawIslandId, ...props } = decodedProps; const islandId = typeof rawIslandId === 'string' ? rawIslandId : undefined; const hydrateMode = isAlsoHydrateMode(rawHydrateMode) ? rawHydrateMode : null; // Look up the component path const componentPath = registry.getServerIslandPath(componentName); if (!componentPath) { return new Response('Unknown server island component', { status: 404 }); } return requestContext.run(ctx, async () => { // A miss here means the build's eager discovery (see build.ts) didn't // find this island; `compileAll` warns about any manifest miss, so the // request-path compile this endpoint is supposed to prevent is never // silent. await registry.compile(componentPath); let result: RenderResult; try { // Namespacing via `idPrefix` keeps `$props.id()` values from this // standalone render from colliding with ids the host page already // emitted (both renders otherwise start their uid counter at `s1`). // Svelte rejects prefixes containing `--`, so guard against tokens // signed by an older deploy carrying an incompatible id. result = await registry.renderComponent(componentPath, props as Record, { stripMarkers: false, ...(islandId && !islandId.includes('--') ? { idPrefix: islandId } : {}), // An also-hydrate island's standalone render seeds the // `isHydratable()` context for its whole subtree — the same signal // the in-page boundary component provides for `mochi:hydrate*` // islands. Pure `mochi:defer` never hydrates, so no context. ...(hydrateMode !== null ? { context: new Map([[HYDRATABLE_CONTEXT_KEY, true]]) } : {}), // Named-export islands render that export, not the module's default. ...(registry.getServerIslandExport(componentName) ? { exportName: registry.getServerIslandExport(componentName) } : {}), }); } catch (err) { const e = err instanceof Error ? err : new Error(String(err)); logger.error(`Server island "${componentName}" failed: ${e.message}`); mochiEvents.emit('island:error', { componentName, islandId, kind: 'server', message: e.message, stack: registry.development ? e.stack : undefined, }); // 200 + a known stub so `ServerIsland.ts` doesn't burn its retry budget // on a deterministic failure. Visibility is CSS-controlled: dev shows // the message, prod hides the element entirely. const stub = islandFailureStub(componentName, registry.development ? e.message : undefined); return new Response(stub, { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'private, no-store', }, }); } let body = result.body; if (isAlsoHydrateMode(hydrateMode)) { const componentUrl = registry.getComponentEntryUrl(componentName); const serializedProps = devalueStringify(props); const bootstrapUrl = registry.getIslandBootstrapUrl(); let hydrateAttrs = `component-name="${componentName}"`; if (Object.keys(props as Record).length > 0) { hydrateAttrs += ` props="${escapeHtmlAttr(serializedProps)}"`; } if (componentUrl) { hydrateAttrs += ` component-url="${componentUrl}"`; } body = `${body}`; if (bootstrapUrl) { body += ``; } } // CSS for islands rendered only inside this deferred content is gated out of the page ``, so its `` // tags are prepended here along with side-effect CSS imports; browsers honour a `` assigned via `innerHTML`. // The island's own scoped CSS is excluded, since the wrapper's `css-url` attribute already loads it. const ownCss = registry.getComponentCssUrl(componentPath); const extraCss = result.cssUrls.filter((url) => url !== ownCss); if (extraCss.length > 0) { body = extraCss.map(cssLinkTag).join('') + body; } return new Response(body, { headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'private, no-store', }, }); }); }); // Gives `Mochi.email()` the live compile cache, so Svelte email templates render through the same registry as page routes. getEmailRuntime().registry = registry; // The resolved options are the single source of truth for `enabled`; `getImageUrl` reads the same flag to fall back // to raw source URLs when the endpoint is off. let stopImageSweeper: (() => void) | undefined; const imageRuntime = getImageRuntime(); if (imageRuntime.options.enabled) { const imageHandler = createImageHandler(); bunRoutes[`${registry.assetPrefix}/image/:filename`] = withHead(async (req: Request): Promise => { const start = performance.now(); const response = await imageHandler(req); const url = new URL(req.url); mochiEvents.emit('request', { requestId: newRequestId(req), kind: 'image', method: req.method, path: url.pathname + url.search, status: response.status, duration: performance.now() - start, }); return response; }); stopImageSweeper = startImageCacheSweeper(imageRuntime.cache, imageRuntime.options.sweepIntervalMs); } // Plain static serving of locally-imported image assets (`import x from './x.png'`), so it registers independently // of `image.enabled`. The handler reads the global registry the build populated, letting new dev images appear // without a route reload. bunRoutes[`${registry.assetPrefix}/asset/:filename`] = withHead(createLocalAssetHandler(development)); // The debug bar's Cache tab reads the entry count (GET) and empties the image cache (POST). It registers with the // debug bar rather than the image endpoint, since the tab always shows and acting on an empty cache is a no-op. if (debugBarEnabled) { const imageCacheHandler = async (req: Request): Promise => { if (req.method === 'POST') { await imageRuntime.cache.clearAll(); return Response.json({ ok: true, count: 0, keys: [] }); } if (req.method === 'GET') { // `keys` already excludes transient in-flight markers, so the debug bar badge matches the number of listed keys. const keys = await imageRuntime.cache.keys(); return Response.json({ count: keys.length, keys }); } return new Response('Method Not Allowed', { status: 405 }); }; // Returns the raw stored entry for a single key (`?key=`). const imageCacheEntryHandler = async (req: Request): Promise => { if (req.method !== 'GET') { return new Response('Method Not Allowed', { status: 405 }); } const key = new URL(req.url).searchParams.get('key'); if (!key) { return new Response('Missing ?key', { status: 400 }); } const value = await imageRuntime.cache.inspect(key); if (value == null) { // 410 marks a key evicted between listing and expanding, which a 404 would leave indistinguishable from an // unregistered route or a mangled key. return new Response('Gone', { status: 410 }); } return Response.json({ key, value }); }; // Register both slash variants so they work under any `trailingSlash` policy. bunRoutes[`${registry.assetPrefix}/image-cache`] = imageCacheHandler; bunRoutes[`${registry.assetPrefix}/image-cache/`] = imageCacheHandler; bunRoutes[`${registry.assetPrefix}/image-cache/entry`] = imageCacheEntryHandler; bunRoutes[`${registry.assetPrefix}/image-cache/entry/`] = imageCacheEntryHandler; } if (process.env.MOCHI_MEMORY_PROBE === '1') { bunRoutes['/__mochi/health/memory'] = (): Response => { Bun.gc(true); return Response.json({ timestamp: Date.now(), memory: process.memoryUsage(), }); }; } // Snapshotted so the dev-mode public watcher can rebuild cleanly when files are added, removed, or renamed. const baseBunRoutes: Record = { ...bunRoutes }; // Every mode scans `publicDir` from disk, and user-defined routes win, so a public route is added only where no user // route claims the path. The dev-watcher reload rebuilds them through these same helpers. const initialPublicFiles = await resolvePublicFiles({ publicDir, development }); // The build copies nothing, so on disk a deploy that ships the build output and forgets publicDir is // indistinguishable from an app that never had static files, leaving the build-time count the only witness. It // warns rather than throws, since dropping the directory on purpose is legitimate. if (!development && registry.loadedFromManifest && registry.publicFileCountAtBuild > 0 && initialPublicFiles.size === 0) { logger.warn( `publicDir "${relForDisplay(publicDir)}" is missing or empty, but the build found ${registry.publicFileCountAtBuild} file(s) there — every static file will 404. ` + `The build never copies publicDir; the runtime reads it on every boot, so that directory has to ship with your deploy ` + `(in Docker, a COPY for it in the final stage — and check .dockerignore). ` + `If it moved, point \`publicDir\` at the new location; if the files are gone on purpose, re-run \`mochi-framework build\` to clear this.`, ); } registerPublicRoutes(bunRoutes, initialPublicFiles); const userFetch = options.fetch; const composedFetch = async (req: Request, server: Server): Promise => { const url = buildPublicUrl(req, options.proxy); if (trailingSlashPolicy) { const redirect = applyFilter('trailingSlash:redirect', trailingSlashRedirect(req.method, url, trailingSlashPolicy), { request: req, url, policy: trailingSlashPolicy }); if (redirect) { return redirect; } } const csrfResponse = csrfCheck(req, url, options.csrf, options.proxy, development, formContentTypes, protectedMethods, trustedOrigins); if (csrfResponse) { return csrfResponse; } // Non-route requests run middleware too, so static-asset paths (`/_mochi/client/...` bundles) share the chain and a // user `gzip()` compresses them like any other response. Kind is precomputed so middleware can branch on it. const assetContent = registry.getClientFile(url.pathname); const kind: MochiEventKind = assetContent !== undefined ? 'asset' : userFetch ? 'fallback' : 'error'; const event: MochiEvent = { request: req, url, server, locals: {}, kind, isWarmup: false }; // `_event` exists only for parity with `MochiResolveFn`; `url`, `req`, and `assetContent` are already fixed in the // enclosing scope. const innerResolve = async (_event: MochiEvent, resolveOpts?: MochiResolveOptions): Promise => { if (assetContent !== undefined) { // `getClientFile()` returns only registered `.js` or `.css`, so extension alone decides and this branch stays // independent of the asset prefix. const contentType = url.pathname.endsWith('.css') ? 'text/css' : 'application/javascript'; const headers: Record = { 'Content-Type': contentType }; // Content-hashed filenames change URL whenever bytes change, so prod can mark them immutable; dev skips it to // keep live-reload edits out of the browser cache. if (!development) { headers['Cache-Control'] = 'public, max-age=31536000, immutable'; } return applyResolveOptions(new Response(assetContent, { headers }), resolveOpts); } if (userFetch) { const response = await userFetch(req, server); return applyResolveOptions(response, resolveOpts); } return renderErrorResponse({ req, event, resolveOpts, status: 404, message: 'Not Found', thrown: null, }); }; const start = performance.now(); const requestId = newRequestId(req); const response = await (middleware ? middleware({ event, resolve: innerResolve }) : innerResolve(event)); mochiEvents.emit('request', { requestId, kind, method: req.method, path: url.pathname + url.search, status: response.status, duration: performance.now() - start, }); if (req.method === 'HEAD') { return headResponse(response); } return response; }; const { routes: _routes, fetch: _fetch, htmlShell: _htmlShell, handle: _handle, markdown: _markdown, websocket: userWebSocketOptions, ...bunOptions } = options as Record; // Registered before the dispatcher is built, so the internal live-reload socket shares the same Bun WebSocket option // as user `Mochi.ws()` routes and keeps `wsHandlersMap.size > 0` true even with no user WebSocket routes. const liveReloadClients = new Set>(); let stopEmailBadgeBroadcast: (() => void) | undefined; if (liveReloadEnabled) { wsHandlersMap.set('/__mochi_live_reload', { open(ws) { const client = ws as ServerWebSocket; liveReloadClients.add(client); try { client.send(liveReloadGreeting(client.data.__mochiEntry)); } catch { liveReloadClients.delete(client); } }, // Proxies and sleeping network stacks swallow protocol pings, leaving a socket that reads OPEN but is dead, so // the client heartbeat needs an application-level reply. message(ws, message) { if (typeof message === 'string' && message === 'ping') { ws.send('pong'); } }, close(ws) { liveReloadClients.delete(ws as ServerWebSocket); }, }); // Reusing the live-reload socket for dev-outbox arrivals lets open tabs surface a "new email" badge without a // second WebSocket, and the captured id lets the toolbar track which messages are still unread. stopEmailBadgeBroadcast = onDevEmailRecorded((email) => { for (const client of liveReloadClients) { try { client.send(`email:new:${email.id}`); } catch { liveReloadClients.delete(client); } } }); } const websocketOption = wsHandlersMap.size > 0 ? { ...(typeof userWebSocketOptions === 'object' ? userWebSocketOptions : {}), open(ws: ServerWebSocket) { wsHandlersMap.get(ws.data.__mochiRoutePattern)?.open?.(ws); }, message(ws: ServerWebSocket, message: string | Buffer) { wsHandlersMap.get(ws.data.__mochiRoutePattern)?.message(ws, message); mochiEvents.emit('ws:message', { path: ws.data.__mochiPath, size: typeof message === 'string' ? Buffer.byteLength(message, 'utf8') : message.byteLength, type: typeof message === 'string' ? 'text' : 'binary', }); }, close(ws: ServerWebSocket, code: number, reason: string) { wsHandlersMap.get(ws.data.__mochiRoutePattern)?.close?.(ws, code, reason); mochiEvents.emit('ws:close', { path: ws.data.__mochiPath, duration: performance.now() - ws.data.__mochiOpenedAt, code, reason, }); }, drain(ws: ServerWebSocket) { wsHandlersMap.get(ws.data.__mochiRoutePattern)?.drain?.(ws); }, } : userWebSocketOptions; // Validating before binding makes a misconfiguration fail fast, leaving no half-started server listening. for (const [name, config] of Object.entries(options.queues ?? {})) { if (!isMochiQueue(config)) { throw new Error(`Mochi.serve({ queues }): "${name}" is not a Mochi.queue(...) descriptor. Each value must be created with Mochi.queue().`); } } const server = Bun.serve({ ...bunOptions, routes: bunRoutes, fetch: composedFetch, ...(websocketOption ? { websocket: websocketOption } : {}), } as Parameters[0]); // Tie subsystem cleanup to the server's lifetime: any stop path (tests // calling server.stop(), or the signal handler below) clears the image-cache // sweep timers and closes a pooled SMTP connection instead of leaking them. // Wrapping stop covers both, since the signal handler calls server.stop(). { const sweeperStop = stopImageSweeper; const stopServer = server.stop.bind(server); // The shutdown path calls `stop()` twice — graceful, then forced once the grace period lapses — and a second // teardown would double-close an already-closed transport. let cleanedUp = false; server.stop = (async (closeActiveConnections?: boolean) => { if (cleanedUp) { return stopServer(closeActiveConnections); } cleanedUp = true; // Subsystem cleanup must never gate the socket close: a transport whose // close() throws (e.g. a nodemailer pool) would otherwise leave the // listener open and hang shutdown. Best-effort, then always stop. try { sweeperStop?.(); stopEmailBadgeBroadcast?.(); await closeEmailTransport(); for (const store of rateLimitStores) { // Per-store guard: one failing shutdown must not skip the rest. try { await store.shutdown?.(); } catch (err) { logger.warn(`Rate limit store shutdown failed: ${err instanceof Error ? err.message : err}`); } } } catch (err) { logger.warn(`Subsystem cleanup failed during shutdown: ${err instanceof Error ? err.message : err}`); } return stopServer(closeActiveConnections); }) as typeof server.stop; } await runHook('mochi:listening', { options, server }); { const startEvent: MochiServerStartEvent = { development, routes: { ...routeCounts }, }; if (typeof server.port === 'number') { startEvent.port = server.port; } if (server.hostname) { startEvent.hostname = server.hostname; } mochiEvents.emit('server:start', startEvent); } // Mounted after bind so the queues drain on the same shutdown path as the server; a throw mid-mount tears the // just-bound server down rather than leaving it listening half-started. try { for (const [name, config] of Object.entries(options.queues ?? {})) { createQueue(name, config.process, config.options, config.on); } } catch (err) { await closeAllQueueResources(); await server.stop(true); throw err; } // Fires once every queue in the map is registered, so a `recover()` callback or user hook reaching for a sibling // gets its handle instead of a "not mounted yet" error. await runHook('mochi:queuesMounted', { options, server, queues: Object.keys(options.queues ?? {}) }); // Awaited so recovered jobs are enqueued before `mochi:ready` fires and before `serve()` resolves. await runQueueRecovery(Object.entries(options.queues ?? {})); if (warmupHandlers.length > 0) { mochiEvents.emit('warmup:start', { routeCount: warmupHandlers.length }); const t0 = performance.now(); // SSR is CPU-bound and serializes on the single thread, so parallel warming would render no faster while smearing // every route's `request` duration into the batch total; one at a time keeps per-route timings honest. void (async () => { let errorCount = 0; for (const { pattern, handler } of warmupHandlers) { // The canonical path keeps the trailing-slash policy from redirecting early instead of running the render being warmed. const url = new URL(`http://localhost${pattern}`); const redirect = trailingSlashPolicy ? trailingSlashRedirect('GET', url, trailingSlashPolicy) : null; const href = redirect ? new URL(redirect.headers.get('Location') ?? pattern, url).href : url.href; try { // The handler swallows render errors and returns a 5xx error page, so 5xx counts as "didn't warm cleanly" // alongside a throw. 4xx is expected — an auth-gated route seeing the anonymous warmup visitor. const response = await handler(markWarmupRequest(new Request(href)), server); if (response.status >= 500) { errorCount += 1; } } catch { errorCount += 1; } } mochiEvents.emit('warmup:complete', { routeCount: warmupHandlers.length, errorCount, durationMs: performance.now() - t0, }); })(); } if (development) { await startDevWatcher({ registry, server, options, liveReloadClients, composedFetch, baseBunRoutes, bunRoutes, outDir, publicDir, watchPaths, development, entryPath: Bun.main, apiHandlerMap, sseHandlerMap, wsHandlersMap, pageConfigMap, registerRoutePattern, unregisterRoutePattern, updateRouteLimiter, trailingSlashPolicy, shellPath, reloadShell, }); } Mochi.installShutdownHandlers(options, server, development); await runHook('mochi:ready', { options, server }); return server; } /** * Install one-shot SIGTERM/SIGINT listeners that fire the `mochi:shutdown` hook and stop the server, with a second * signal force-exiting as most CLIs do. */ private static installShutdownHandlers(options: MochiServeOptions, server: Server, development: boolean): void { let shuttingDown = false; const handle = async (signal: NodeJS.Signals): Promise => { if (shuttingDown) { process.exit(1); } shuttingDown = true; logger.info(`Received ${signal}, shutting down…`); try { await runHook('mochi:shutdown', { options, server, signal }); } catch (err) { logger.error(`mochi:shutdown hook failed: ${err instanceof Error ? err.message : err}`); } await closeAllQueueResources(); resetStartupMilestones(); const stopEvent: MochiServerStopEvent = { reason: 'signal' }; if (signal === 'SIGTERM' || signal === 'SIGINT') { stopEvent.signal = signal; } mochiEvents.emit('server:stop', stopEvent); // A non-forced `stop()` waits for every connection to drain and Bun never resolves it while a WebSocket is open, // so in dev a single tab holding the live-reload socket wedges the process; the graceful stop gets the grace // period alone, then connections are cut regardless. const timeout = options.shutdownTimeout ?? (development ? 0 : 5_000); if (timeout > 0) { // Once the grace period wins the race, a late rejection from the graceful stop has no one left to await it. const graceful = server.stop().catch((err: unknown) => { logger.warn(`Graceful stop failed: ${err instanceof Error ? err.message : err}`); }); await Promise.race([graceful, Bun.sleep(timeout)]); } await server.stop(true); // chokidar's dev watchers and any user timer still running would otherwise keep the event loop alive past the last socket. process.exit(0); }; process.on('SIGTERM', handle); process.on('SIGINT', handle); } }