@timber-js/app 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/timber.mjs +5 -0
- package/dist/_chunks/error-boundary-dj-WO5uq.js +121 -0
- package/dist/_chunks/error-boundary-dj-WO5uq.js.map +1 -0
- package/dist/_chunks/format-DNt20Kt8.js +163 -0
- package/dist/_chunks/format-DNt20Kt8.js.map +1 -0
- package/dist/_chunks/interception-DIaZN1bF.js +669 -0
- package/dist/_chunks/interception-DIaZN1bF.js.map +1 -0
- package/dist/_chunks/metadata-routes-BDnswgRO.js +141 -0
- package/dist/_chunks/metadata-routes-BDnswgRO.js.map +1 -0
- package/dist/_chunks/registry-DUIpYD_x.js +20 -0
- package/dist/_chunks/registry-DUIpYD_x.js.map +1 -0
- package/dist/_chunks/request-context-D6XHINkR.js +330 -0
- package/dist/_chunks/request-context-D6XHINkR.js.map +1 -0
- package/dist/_chunks/tracing-BtOwb8O6.js +174 -0
- package/dist/_chunks/tracing-BtOwb8O6.js.map +1 -0
- package/dist/_chunks/use-cookie-8ZlA0rr3.js +125 -0
- package/dist/_chunks/use-cookie-8ZlA0rr3.js.map +1 -0
- package/dist/adapters/cloudflare.d.ts +92 -0
- package/dist/adapters/cloudflare.d.ts.map +1 -0
- package/dist/adapters/cloudflare.js +188 -0
- package/dist/adapters/cloudflare.js.map +1 -0
- package/dist/adapters/nitro.d.ts +72 -0
- package/dist/adapters/nitro.d.ts.map +1 -0
- package/dist/adapters/nitro.js +217 -0
- package/dist/adapters/nitro.js.map +1 -0
- package/dist/adapters/types.d.ts +53 -0
- package/dist/adapters/types.d.ts.map +1 -0
- package/dist/cache/index.d.ts +52 -0
- package/dist/cache/index.d.ts.map +1 -0
- package/dist/cache/index.js +283 -0
- package/dist/cache/index.js.map +1 -0
- package/dist/cache/redis-handler.d.ts +45 -0
- package/dist/cache/redis-handler.d.ts.map +1 -0
- package/dist/cache/register-cached-function.d.ts +17 -0
- package/dist/cache/register-cached-function.d.ts.map +1 -0
- package/dist/cache/singleflight.d.ts +11 -0
- package/dist/cache/singleflight.d.ts.map +1 -0
- package/dist/cache/stable-stringify.d.ts +7 -0
- package/dist/cache/stable-stringify.d.ts.map +1 -0
- package/dist/cache/timber-cache.d.ts +21 -0
- package/dist/cache/timber-cache.d.ts.map +1 -0
- package/dist/cli.d.ts +44 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +135 -0
- package/dist/cli.js.map +1 -0
- package/dist/client/browser-entry.d.ts +22 -0
- package/dist/client/browser-entry.d.ts.map +1 -0
- package/dist/client/error-boundary.d.ts +42 -0
- package/dist/client/error-boundary.d.ts.map +1 -0
- package/dist/client/form.d.ts +115 -0
- package/dist/client/form.d.ts.map +1 -0
- package/dist/client/head.d.ts +16 -0
- package/dist/client/head.d.ts.map +1 -0
- package/dist/client/history.d.ts +29 -0
- package/dist/client/history.d.ts.map +1 -0
- package/dist/client/index.d.ts +32 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +1218 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/link-navigate-interceptor.d.ts +28 -0
- package/dist/client/link-navigate-interceptor.d.ts.map +1 -0
- package/dist/client/link-status-provider.d.ts +11 -0
- package/dist/client/link-status-provider.d.ts.map +1 -0
- package/dist/client/link.d.ts +119 -0
- package/dist/client/link.d.ts.map +1 -0
- package/dist/client/nuqs-adapter.d.ts +11 -0
- package/dist/client/nuqs-adapter.d.ts.map +1 -0
- package/dist/client/router-ref.d.ts +11 -0
- package/dist/client/router-ref.d.ts.map +1 -0
- package/dist/client/router.d.ts +85 -0
- package/dist/client/router.d.ts.map +1 -0
- package/dist/client/segment-cache.d.ts +88 -0
- package/dist/client/segment-cache.d.ts.map +1 -0
- package/dist/client/segment-context.d.ts +32 -0
- package/dist/client/segment-context.d.ts.map +1 -0
- package/dist/client/ssr-data.d.ts +64 -0
- package/dist/client/ssr-data.d.ts.map +1 -0
- package/dist/client/types.d.ts +5 -0
- package/dist/client/types.d.ts.map +1 -0
- package/dist/client/unload-guard.d.ts +18 -0
- package/dist/client/unload-guard.d.ts.map +1 -0
- package/dist/client/use-cookie.d.ts +37 -0
- package/dist/client/use-cookie.d.ts.map +1 -0
- package/dist/client/use-link-status.d.ts +35 -0
- package/dist/client/use-link-status.d.ts.map +1 -0
- package/dist/client/use-navigation-pending.d.ts +26 -0
- package/dist/client/use-navigation-pending.d.ts.map +1 -0
- package/dist/client/use-params.d.ts +50 -0
- package/dist/client/use-params.d.ts.map +1 -0
- package/dist/client/use-pathname.d.ts +20 -0
- package/dist/client/use-pathname.d.ts.map +1 -0
- package/dist/client/use-query-states.d.ts +36 -0
- package/dist/client/use-query-states.d.ts.map +1 -0
- package/dist/client/use-router.d.ts +39 -0
- package/dist/client/use-router.d.ts.map +1 -0
- package/dist/client/use-search-params.d.ts +24 -0
- package/dist/client/use-search-params.d.ts.map +1 -0
- package/dist/client/use-selected-layout-segment.d.ts +68 -0
- package/dist/client/use-selected-layout-segment.d.ts.map +1 -0
- package/dist/content/index.d.ts +11 -0
- package/dist/content/index.d.ts.map +1 -0
- package/dist/content/index.js +2 -0
- package/dist/cookies/define-cookie.d.ts +61 -0
- package/dist/cookies/define-cookie.d.ts.map +1 -0
- package/dist/cookies/index.d.ts +3 -0
- package/dist/cookies/index.d.ts.map +1 -0
- package/dist/cookies/index.js +82 -0
- package/dist/cookies/index.js.map +1 -0
- package/dist/fonts/ast.d.ts +38 -0
- package/dist/fonts/ast.d.ts.map +1 -0
- package/dist/fonts/css.d.ts +43 -0
- package/dist/fonts/css.d.ts.map +1 -0
- package/dist/fonts/fallbacks.d.ts +36 -0
- package/dist/fonts/fallbacks.d.ts.map +1 -0
- package/dist/fonts/google.d.ts +122 -0
- package/dist/fonts/google.d.ts.map +1 -0
- package/dist/fonts/local.d.ts +76 -0
- package/dist/fonts/local.d.ts.map +1 -0
- package/dist/fonts/types.d.ts +85 -0
- package/dist/fonts/types.d.ts.map +1 -0
- package/dist/index.d.ts +150 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14701 -0
- package/dist/index.js.map +1 -0
- package/dist/plugins/adapter-build.d.ts +18 -0
- package/dist/plugins/adapter-build.d.ts.map +1 -0
- package/dist/plugins/build-manifest.d.ts +79 -0
- package/dist/plugins/build-manifest.d.ts.map +1 -0
- package/dist/plugins/build-report.d.ts +63 -0
- package/dist/plugins/build-report.d.ts.map +1 -0
- package/dist/plugins/cache-transform.d.ts +36 -0
- package/dist/plugins/cache-transform.d.ts.map +1 -0
- package/dist/plugins/chunks.d.ts +45 -0
- package/dist/plugins/chunks.d.ts.map +1 -0
- package/dist/plugins/content.d.ts +19 -0
- package/dist/plugins/content.d.ts.map +1 -0
- package/dist/plugins/dev-error-overlay.d.ts +60 -0
- package/dist/plugins/dev-error-overlay.d.ts.map +1 -0
- package/dist/plugins/dev-logs.d.ts +46 -0
- package/dist/plugins/dev-logs.d.ts.map +1 -0
- package/dist/plugins/dev-server.d.ts +22 -0
- package/dist/plugins/dev-server.d.ts.map +1 -0
- package/dist/plugins/dynamic-transform.d.ts +72 -0
- package/dist/plugins/dynamic-transform.d.ts.map +1 -0
- package/dist/plugins/entries.d.ts +21 -0
- package/dist/plugins/entries.d.ts.map +1 -0
- package/dist/plugins/fonts.d.ts +77 -0
- package/dist/plugins/fonts.d.ts.map +1 -0
- package/dist/plugins/mdx.d.ts +21 -0
- package/dist/plugins/mdx.d.ts.map +1 -0
- package/dist/plugins/react-prod.d.ts +18 -0
- package/dist/plugins/react-prod.d.ts.map +1 -0
- package/dist/plugins/routing.d.ts +13 -0
- package/dist/plugins/routing.d.ts.map +1 -0
- package/dist/plugins/server-action-exports.d.ts +26 -0
- package/dist/plugins/server-action-exports.d.ts.map +1 -0
- package/dist/plugins/server-bundle.d.ts +15 -0
- package/dist/plugins/server-bundle.d.ts.map +1 -0
- package/dist/plugins/shims.d.ts +18 -0
- package/dist/plugins/shims.d.ts.map +1 -0
- package/dist/plugins/static-build.d.ts +55 -0
- package/dist/plugins/static-build.d.ts.map +1 -0
- package/dist/routing/codegen.d.ts +29 -0
- package/dist/routing/codegen.d.ts.map +1 -0
- package/dist/routing/index.d.ts +8 -0
- package/dist/routing/index.d.ts.map +1 -0
- package/dist/routing/index.js +2 -0
- package/dist/routing/interception.d.ts +46 -0
- package/dist/routing/interception.d.ts.map +1 -0
- package/dist/routing/scanner.d.ts +28 -0
- package/dist/routing/scanner.d.ts.map +1 -0
- package/dist/routing/status-file-lint.d.ts +33 -0
- package/dist/routing/status-file-lint.d.ts.map +1 -0
- package/dist/routing/types.d.ts +81 -0
- package/dist/routing/types.d.ts.map +1 -0
- package/dist/search-params/analyze.d.ts +54 -0
- package/dist/search-params/analyze.d.ts.map +1 -0
- package/dist/search-params/codecs.d.ts +53 -0
- package/dist/search-params/codecs.d.ts.map +1 -0
- package/dist/search-params/create.d.ts +106 -0
- package/dist/search-params/create.d.ts.map +1 -0
- package/dist/search-params/index.d.ts +7 -0
- package/dist/search-params/index.d.ts.map +1 -0
- package/dist/search-params/index.js +300 -0
- package/dist/search-params/index.js.map +1 -0
- package/dist/search-params/registry.d.ts +20 -0
- package/dist/search-params/registry.d.ts.map +1 -0
- package/dist/server/access-gate.d.ts +42 -0
- package/dist/server/access-gate.d.ts.map +1 -0
- package/dist/server/action-client.d.ts +190 -0
- package/dist/server/action-client.d.ts.map +1 -0
- package/dist/server/action-handler.d.ts +48 -0
- package/dist/server/action-handler.d.ts.map +1 -0
- package/dist/server/actions.d.ts +108 -0
- package/dist/server/actions.d.ts.map +1 -0
- package/dist/server/asset-headers.d.ts +42 -0
- package/dist/server/asset-headers.d.ts.map +1 -0
- package/dist/server/body-limits.d.ts +30 -0
- package/dist/server/body-limits.d.ts.map +1 -0
- package/dist/server/build-manifest.d.ts +120 -0
- package/dist/server/build-manifest.d.ts.map +1 -0
- package/dist/server/canonicalize.d.ts +30 -0
- package/dist/server/canonicalize.d.ts.map +1 -0
- package/dist/server/client-module-map.d.ts +47 -0
- package/dist/server/client-module-map.d.ts.map +1 -0
- package/dist/server/csrf.d.ts +34 -0
- package/dist/server/csrf.d.ts.map +1 -0
- package/dist/server/deny-renderer.d.ts +49 -0
- package/dist/server/deny-renderer.d.ts.map +1 -0
- package/dist/server/dev-logger.d.ts +44 -0
- package/dist/server/dev-logger.d.ts.map +1 -0
- package/dist/server/dev-span-processor.d.ts +29 -0
- package/dist/server/dev-span-processor.d.ts.map +1 -0
- package/dist/server/dev-warnings.d.ts +129 -0
- package/dist/server/dev-warnings.d.ts.map +1 -0
- package/dist/server/early-hints-sender.d.ts +38 -0
- package/dist/server/early-hints-sender.d.ts.map +1 -0
- package/dist/server/early-hints.d.ts +83 -0
- package/dist/server/early-hints.d.ts.map +1 -0
- package/dist/server/error-boundary-wrapper.d.ts +17 -0
- package/dist/server/error-boundary-wrapper.d.ts.map +1 -0
- package/dist/server/error-formatter.d.ts +17 -0
- package/dist/server/error-formatter.d.ts.map +1 -0
- package/dist/server/flush.d.ts +74 -0
- package/dist/server/flush.d.ts.map +1 -0
- package/dist/server/form-data.d.ts +60 -0
- package/dist/server/form-data.d.ts.map +1 -0
- package/dist/server/form-flash.d.ts +78 -0
- package/dist/server/form-flash.d.ts.map +1 -0
- package/dist/server/html-injectors.d.ts +101 -0
- package/dist/server/html-injectors.d.ts.map +1 -0
- package/dist/server/index.d.ts +54 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +2925 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/instrumentation.d.ts +61 -0
- package/dist/server/instrumentation.d.ts.map +1 -0
- package/dist/server/logger.d.ts +83 -0
- package/dist/server/logger.d.ts.map +1 -0
- package/dist/server/manifest-status-resolver.d.ts +58 -0
- package/dist/server/manifest-status-resolver.d.ts.map +1 -0
- package/dist/server/metadata-render.d.ts +20 -0
- package/dist/server/metadata-render.d.ts.map +1 -0
- package/dist/server/metadata-routes.d.ts +67 -0
- package/dist/server/metadata-routes.d.ts.map +1 -0
- package/dist/server/metadata.d.ts +67 -0
- package/dist/server/metadata.d.ts.map +1 -0
- package/dist/server/middleware-runner.d.ts +21 -0
- package/dist/server/middleware-runner.d.ts.map +1 -0
- package/dist/server/nuqs-ssr-provider.d.ts +28 -0
- package/dist/server/nuqs-ssr-provider.d.ts.map +1 -0
- package/dist/server/pipeline.d.ts +81 -0
- package/dist/server/pipeline.d.ts.map +1 -0
- package/dist/server/prerender.d.ts +77 -0
- package/dist/server/prerender.d.ts.map +1 -0
- package/dist/server/primitives.d.ts +131 -0
- package/dist/server/primitives.d.ts.map +1 -0
- package/dist/server/proxy.d.ts +23 -0
- package/dist/server/proxy.d.ts.map +1 -0
- package/dist/server/request-context.d.ts +175 -0
- package/dist/server/request-context.d.ts.map +1 -0
- package/dist/server/route-element-builder.d.ts +66 -0
- package/dist/server/route-element-builder.d.ts.map +1 -0
- package/dist/server/route-handler.d.ts +35 -0
- package/dist/server/route-handler.d.ts.map +1 -0
- package/dist/server/route-matcher.d.ts +78 -0
- package/dist/server/route-matcher.d.ts.map +1 -0
- package/dist/server/rsc-entry/api-handler.d.ts +11 -0
- package/dist/server/rsc-entry/api-handler.d.ts.map +1 -0
- package/dist/server/rsc-entry/error-renderer.d.ts +30 -0
- package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -0
- package/dist/server/rsc-entry/helpers.d.ts +73 -0
- package/dist/server/rsc-entry/helpers.d.ts.map +1 -0
- package/dist/server/rsc-entry/index.d.ts +11 -0
- package/dist/server/rsc-entry/index.d.ts.map +1 -0
- package/dist/server/rsc-entry/ssr-bridge.d.ts +6 -0
- package/dist/server/rsc-entry/ssr-bridge.d.ts.map +1 -0
- package/dist/server/slot-resolver.d.ts +34 -0
- package/dist/server/slot-resolver.d.ts.map +1 -0
- package/dist/server/ssr-entry.d.ts +73 -0
- package/dist/server/ssr-entry.d.ts.map +1 -0
- package/dist/server/ssr-render.d.ts +67 -0
- package/dist/server/ssr-render.d.ts.map +1 -0
- package/dist/server/status-code-resolver.d.ts +77 -0
- package/dist/server/status-code-resolver.d.ts.map +1 -0
- package/dist/server/tracing.d.ts +99 -0
- package/dist/server/tracing.d.ts.map +1 -0
- package/dist/server/tree-builder.d.ts +116 -0
- package/dist/server/tree-builder.d.ts.map +1 -0
- package/dist/server/types.d.ts +231 -0
- package/dist/server/types.d.ts.map +1 -0
- package/dist/shims/font-google.d.ts +41 -0
- package/dist/shims/font-google.d.ts.map +1 -0
- package/dist/shims/headers.d.ts +11 -0
- package/dist/shims/headers.d.ts.map +1 -0
- package/dist/shims/image.d.ts +328 -0
- package/dist/shims/image.d.ts.map +1 -0
- package/dist/shims/link.d.ts +9 -0
- package/dist/shims/link.d.ts.map +1 -0
- package/dist/shims/navigation-client.d.ts +25 -0
- package/dist/shims/navigation-client.d.ts.map +1 -0
- package/dist/shims/navigation.d.ts +25 -0
- package/dist/shims/navigation.d.ts.map +1 -0
- package/dist/utils/directive-parser.d.ts +70 -0
- package/dist/utils/directive-parser.d.ts.map +1 -0
- package/dist/utils/format.d.ts +6 -0
- package/dist/utils/format.d.ts.map +1 -0
- package/dist/utils/startup-timer.d.ts +34 -0
- package/dist/utils/startup-timer.d.ts.map +1 -0
- package/package.json +140 -0
|
@@ -0,0 +1,2925 @@
|
|
|
1
|
+
import { a as warnDenyAfterFlush, c as warnRedirectInAccess, d as warnSlowSlotWithoutSuspense, f as warnStaticRequestApi, i as warnCacheRequestProps, l as warnRedirectInSlotAccess, n as WarningId, o as warnDenyInSuspense, p as warnSuspenseWrappingChildren, r as setViteServer, s as warnDynamicApiInStaticBuild, t as formatSize, u as warnRedirectInSuspense } from "../_chunks/format-DNt20Kt8.js";
|
|
2
|
+
import { i as getMetadataRouteServePath, n as classifyMetadataRoute, r as getMetadataRouteAutoLink, t as METADATA_ROUTE_CONVENTIONS } from "../_chunks/metadata-routes-BDnswgRO.js";
|
|
3
|
+
import { a as markResponseFlushed, c as setCookieSecrets, i as headers, l as setMutableCookieContext, n as cookies, o as runWithRequestContext, r as getSetCookieHeaders, s as searchParams, t as applyRequestHeaderOverlay, u as setParsedSearchParams } from "../_chunks/request-context-D6XHINkR.js";
|
|
4
|
+
import { a as replaceTraceId, c as spanId, i as getTraceStore, l as traceId, n as generateTraceId, o as runWithTraceId, r as getOtelTraceId, s as setSpanAttribute, t as addSpanEvent, u as withSpan } from "../_chunks/tracing-BtOwb8O6.js";
|
|
5
|
+
import { t as TimberErrorBoundary } from "../_chunks/error-boundary-dj-WO5uq.js";
|
|
6
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
7
|
+
//#region src/server/primitives.ts
|
|
8
|
+
/**
|
|
9
|
+
* Render-phase signal thrown by `deny()`. Caught by the framework to produce
|
|
10
|
+
* the correct HTTP status code (segment context) or graceful degradation (slot context).
|
|
11
|
+
*/
|
|
12
|
+
var DenySignal = class extends Error {
|
|
13
|
+
status;
|
|
14
|
+
data;
|
|
15
|
+
constructor(status, data) {
|
|
16
|
+
super(`Access denied with status ${status}`);
|
|
17
|
+
this.name = "DenySignal";
|
|
18
|
+
this.status = status;
|
|
19
|
+
this.data = data;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Extract the file that called deny() from the stack trace.
|
|
23
|
+
* Returns a short path (e.g. "app/auth/access.ts") or undefined if
|
|
24
|
+
* the stack can't be parsed. Dev-only — used for dev log output.
|
|
25
|
+
*/
|
|
26
|
+
get sourceFile() {
|
|
27
|
+
if (!this.stack) return void 0;
|
|
28
|
+
const frames = this.stack.split("\n");
|
|
29
|
+
for (let i = 2; i < frames.length; i++) {
|
|
30
|
+
const frame = frames[i];
|
|
31
|
+
if (!frame) continue;
|
|
32
|
+
if (frame.includes("primitives.ts") || frame.includes("node_modules")) continue;
|
|
33
|
+
const match = frame.match(/\(([^)]+?)(?::\d+:\d+)\)/) ?? frame.match(/at\s+([^\s]+?)(?::\d+:\d+)/);
|
|
34
|
+
if (match?.[1]) {
|
|
35
|
+
const full = match[1];
|
|
36
|
+
const appIdx = full.indexOf("/app/");
|
|
37
|
+
return appIdx >= 0 ? full.slice(appIdx + 1) : full;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Universal denial primitive. Throws a `DenySignal` that the framework catches.
|
|
44
|
+
*
|
|
45
|
+
* - In segment context (outside Suspense): produces HTTP status code
|
|
46
|
+
* - In slot context: graceful degradation → denied.tsx → default.tsx → null
|
|
47
|
+
* - Inside Suspense (hold window): promoted to pre-flush behavior
|
|
48
|
+
* - Inside Suspense (after flush): error boundary + noindex meta
|
|
49
|
+
*
|
|
50
|
+
* @param status - Any 4xx HTTP status code. Defaults to 403.
|
|
51
|
+
* @param data - Optional data passed as `dangerouslyPassData` prop to status-code files.
|
|
52
|
+
*/
|
|
53
|
+
function deny(status = 403, data) {
|
|
54
|
+
if (status < 400 || status > 499) throw new Error(`deny() requires a 4xx status code, got ${status}. For 5xx errors, throw a RenderError instead.`);
|
|
55
|
+
throw new DenySignal(status, data);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Convenience alias for `deny(404)`.
|
|
59
|
+
*
|
|
60
|
+
* Provided for Next.js API compatibility — libraries and user code that
|
|
61
|
+
* call `notFound()` from `next/navigation` get the same behavior as
|
|
62
|
+
* `deny(404)` in timber.
|
|
63
|
+
*/
|
|
64
|
+
function notFound() {
|
|
65
|
+
throw new DenySignal(404);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Next.js redirect type discriminator.
|
|
69
|
+
*
|
|
70
|
+
* Provided for API compatibility with libraries that import `RedirectType`
|
|
71
|
+
* from `next/navigation`. In timber, `redirect()` always uses `replace`
|
|
72
|
+
* semantics (no history entry for the redirect itself).
|
|
73
|
+
*/
|
|
74
|
+
var RedirectType = {
|
|
75
|
+
push: "push",
|
|
76
|
+
replace: "replace"
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* Render-phase signal thrown by `redirect()` and `redirectExternal()`.
|
|
80
|
+
* Caught by the framework to produce a 3xx response or client-side navigation.
|
|
81
|
+
*/
|
|
82
|
+
var RedirectSignal = class extends Error {
|
|
83
|
+
location;
|
|
84
|
+
status;
|
|
85
|
+
constructor(location, status) {
|
|
86
|
+
super(`Redirect to ${location}`);
|
|
87
|
+
this.name = "RedirectSignal";
|
|
88
|
+
this.location = location;
|
|
89
|
+
this.status = status;
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
/** Pattern matching absolute URLs: http(s):// or protocol-relative // */
|
|
93
|
+
var ABSOLUTE_URL_RE = /^(?:[a-zA-Z][a-zA-Z\d+\-.]*:|\/\/)/;
|
|
94
|
+
/**
|
|
95
|
+
* Redirect to a relative path. Rejects absolute and protocol-relative URLs.
|
|
96
|
+
* Use `redirectExternal()` for external redirects with an allow-list.
|
|
97
|
+
*
|
|
98
|
+
* @param path - Relative path (e.g. '/login', 'settings', '/login?returnTo=/dash')
|
|
99
|
+
* @param status - HTTP redirect status code (3xx). Defaults to 302.
|
|
100
|
+
*/
|
|
101
|
+
function redirect(path, status = 302) {
|
|
102
|
+
if (status < 300 || status > 399) throw new Error(`redirect() requires a 3xx status code, got ${status}.`);
|
|
103
|
+
if (ABSOLUTE_URL_RE.test(path)) throw new Error(`redirect() only accepts relative URLs. Got absolute URL: "${path}". Use redirectExternal(url, allowList) for external redirects.`);
|
|
104
|
+
throw new RedirectSignal(path, status);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Permanent redirect to a relative path. Shorthand for `redirect(path, 308)`.
|
|
108
|
+
*
|
|
109
|
+
* Uses 308 (Permanent Redirect) which preserves the HTTP method — the browser
|
|
110
|
+
* will replay POST requests to the new location. This matches Next.js behavior.
|
|
111
|
+
*
|
|
112
|
+
* @param path - Relative path (e.g. '/new-page', '/dashboard')
|
|
113
|
+
*/
|
|
114
|
+
function permanentRedirect(path) {
|
|
115
|
+
redirect(path, 308);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Redirect to an external URL. The hostname must be in the provided allow-list.
|
|
119
|
+
*
|
|
120
|
+
* @param url - Absolute URL to redirect to.
|
|
121
|
+
* @param allowList - Array of allowed hostnames (e.g. ['example.com', 'auth.example.com']).
|
|
122
|
+
* @param status - HTTP redirect status code (3xx). Defaults to 302.
|
|
123
|
+
*/
|
|
124
|
+
function redirectExternal(url, allowList, status = 302) {
|
|
125
|
+
if (status < 300 || status > 399) throw new Error(`redirectExternal() requires a 3xx status code, got ${status}.`);
|
|
126
|
+
let hostname;
|
|
127
|
+
try {
|
|
128
|
+
hostname = new URL(url).hostname;
|
|
129
|
+
} catch {
|
|
130
|
+
throw new Error(`redirectExternal() received an invalid URL: "${url}"`);
|
|
131
|
+
}
|
|
132
|
+
if (!allowList.includes(hostname)) throw new Error(`redirectExternal() target "${hostname}" is not in the allow-list. Allowed: [${allowList.join(", ")}]`);
|
|
133
|
+
throw new RedirectSignal(url, status);
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Typed throw for render-phase errors that carry structured context to error boundaries.
|
|
137
|
+
*
|
|
138
|
+
* The `digest` (code + data) is serialized into the RSC stream separately from the
|
|
139
|
+
* Error instance — only the digest crosses the RSC → client boundary.
|
|
140
|
+
*
|
|
141
|
+
* @example
|
|
142
|
+
* ```ts
|
|
143
|
+
* throw new RenderError('PRODUCT_NOT_FOUND', {
|
|
144
|
+
* title: 'Product not found',
|
|
145
|
+
* resourceId: params.id,
|
|
146
|
+
* })
|
|
147
|
+
* ```
|
|
148
|
+
*/
|
|
149
|
+
var RenderError = class extends Error {
|
|
150
|
+
code;
|
|
151
|
+
digest;
|
|
152
|
+
status;
|
|
153
|
+
constructor(code, data, options) {
|
|
154
|
+
super(`RenderError: ${code}`);
|
|
155
|
+
this.name = "RenderError";
|
|
156
|
+
this.code = code;
|
|
157
|
+
this.digest = {
|
|
158
|
+
code,
|
|
159
|
+
data
|
|
160
|
+
};
|
|
161
|
+
const status = options?.status ?? 500;
|
|
162
|
+
if (status < 400 || status > 599) throw new Error(`RenderError status must be 4xx or 5xx, got ${status}.`);
|
|
163
|
+
this.status = status;
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
var _waitUntilWarned = false;
|
|
167
|
+
/**
|
|
168
|
+
* Register a promise to be kept alive after the response is sent.
|
|
169
|
+
* Maps to `ctx.waitUntil()` on Cloudflare Workers and similar platforms.
|
|
170
|
+
*
|
|
171
|
+
* If the adapter does not support `waitUntil`, a warning is logged once
|
|
172
|
+
* and the promise is left to resolve (or reject) without being tracked.
|
|
173
|
+
*
|
|
174
|
+
* @param promise - The background work to keep alive.
|
|
175
|
+
* @param adapter - The platform adapter (injected by the framework at runtime).
|
|
176
|
+
*/
|
|
177
|
+
function waitUntil(promise, adapter) {
|
|
178
|
+
if (typeof adapter.waitUntil === "function") {
|
|
179
|
+
adapter.waitUntil(promise);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (!_waitUntilWarned) {
|
|
183
|
+
_waitUntilWarned = true;
|
|
184
|
+
console.warn("[timber] waitUntil() is not supported by the current adapter. Background work will not be tracked. This warning is shown once.");
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
//#endregion
|
|
188
|
+
//#region src/server/canonicalize.ts
|
|
189
|
+
/**
|
|
190
|
+
* Encoded separators that produce a 400 rejection.
|
|
191
|
+
* %2f (/) and %5c (\) cause path-confusion attacks.
|
|
192
|
+
*/
|
|
193
|
+
var ENCODED_SEPARATOR_RE = /%2f|%5c/i;
|
|
194
|
+
/** Null byte — rejected. */
|
|
195
|
+
var NULL_BYTE_RE = /%00/i;
|
|
196
|
+
/**
|
|
197
|
+
* Canonicalize a URL pathname.
|
|
198
|
+
*
|
|
199
|
+
* 1. Reject encoded separators (%2f, %5c) and null bytes (%00)
|
|
200
|
+
* 2. Single percent-decode
|
|
201
|
+
* 3. Collapse // → /
|
|
202
|
+
* 4. Resolve .. segments (reject if escaping root)
|
|
203
|
+
* 5. Strip trailing slash (except root "/")
|
|
204
|
+
*
|
|
205
|
+
* @param rawPathname - The raw pathname from the request URL (percent-encoded)
|
|
206
|
+
* @param stripTrailingSlash - Whether to strip trailing slashes. Default: true.
|
|
207
|
+
*/
|
|
208
|
+
function canonicalize(rawPathname, stripTrailingSlash = true) {
|
|
209
|
+
if (ENCODED_SEPARATOR_RE.test(rawPathname)) return {
|
|
210
|
+
ok: false,
|
|
211
|
+
status: 400
|
|
212
|
+
};
|
|
213
|
+
if (NULL_BYTE_RE.test(rawPathname)) return {
|
|
214
|
+
ok: false,
|
|
215
|
+
status: 400
|
|
216
|
+
};
|
|
217
|
+
let decoded;
|
|
218
|
+
try {
|
|
219
|
+
decoded = decodeURIComponent(rawPathname);
|
|
220
|
+
} catch {
|
|
221
|
+
return {
|
|
222
|
+
ok: false,
|
|
223
|
+
status: 400
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
if (decoded.includes("\0")) return {
|
|
227
|
+
ok: false,
|
|
228
|
+
status: 400
|
|
229
|
+
};
|
|
230
|
+
let pathname = decoded.replace(/\/\/+/g, "/");
|
|
231
|
+
const segments = pathname.split("/");
|
|
232
|
+
const resolved = [];
|
|
233
|
+
for (const seg of segments) if (seg === "..") {
|
|
234
|
+
if (resolved.length <= 1) return {
|
|
235
|
+
ok: false,
|
|
236
|
+
status: 400
|
|
237
|
+
};
|
|
238
|
+
resolved.pop();
|
|
239
|
+
} else if (seg !== ".") resolved.push(seg);
|
|
240
|
+
pathname = resolved.join("/") || "/";
|
|
241
|
+
if (stripTrailingSlash && pathname.length > 1 && pathname.endsWith("/")) pathname = pathname.slice(0, -1);
|
|
242
|
+
return {
|
|
243
|
+
ok: true,
|
|
244
|
+
pathname
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
//#endregion
|
|
248
|
+
//#region src/server/proxy.ts
|
|
249
|
+
/**
|
|
250
|
+
* Run the proxy pipeline.
|
|
251
|
+
*
|
|
252
|
+
* @param proxyExport - The default export from proxy.ts (function or array)
|
|
253
|
+
* @param req - The incoming request
|
|
254
|
+
* @param next - The continuation that proceeds to route matching and rendering
|
|
255
|
+
* @returns The final response
|
|
256
|
+
*/
|
|
257
|
+
async function runProxy(proxyExport, req, next) {
|
|
258
|
+
const fns = Array.isArray(proxyExport) ? proxyExport : [proxyExport];
|
|
259
|
+
let i = fns.length;
|
|
260
|
+
let composed = next;
|
|
261
|
+
while (i--) {
|
|
262
|
+
const fn = fns[i];
|
|
263
|
+
const downstream = composed;
|
|
264
|
+
composed = () => Promise.resolve(fn(req, downstream));
|
|
265
|
+
}
|
|
266
|
+
return composed();
|
|
267
|
+
}
|
|
268
|
+
//#endregion
|
|
269
|
+
//#region src/server/middleware-runner.ts
|
|
270
|
+
/**
|
|
271
|
+
* Run a route's middleware function.
|
|
272
|
+
*
|
|
273
|
+
* @param middlewareFn - The default export from the route's middleware.ts
|
|
274
|
+
* @param ctx - The middleware context (req, params, headers, requestHeaders, searchParams)
|
|
275
|
+
* @returns A Response if middleware short-circuited, or undefined to continue
|
|
276
|
+
*/
|
|
277
|
+
async function runMiddleware(middlewareFn, ctx) {
|
|
278
|
+
const result = await middlewareFn(ctx);
|
|
279
|
+
if (result instanceof Response) return result;
|
|
280
|
+
}
|
|
281
|
+
//#endregion
|
|
282
|
+
//#region src/server/error-formatter.ts
|
|
283
|
+
/**
|
|
284
|
+
* Error Formatter — rewrites SSR/RSC error messages to surface user code.
|
|
285
|
+
*
|
|
286
|
+
* When React or Vite throw errors during SSR, stack traces reference
|
|
287
|
+
* vendored dependency paths (e.g. `.vite/deps_ssr/@vitejs_plugin-rsc_vendor_...`)
|
|
288
|
+
* and mangled export names (`__vite_ssr_export_default__`). This module
|
|
289
|
+
* rewrites error messages and stack traces to point at user code instead.
|
|
290
|
+
*
|
|
291
|
+
* Dev-only — in production, errors go through the structured logger
|
|
292
|
+
* without formatting.
|
|
293
|
+
*/
|
|
294
|
+
/**
|
|
295
|
+
* Patterns that identify internal Vite/RSC vendor paths in stack traces.
|
|
296
|
+
* These are replaced with human-readable labels.
|
|
297
|
+
*/
|
|
298
|
+
var VENDOR_PATH_PATTERNS = [
|
|
299
|
+
{
|
|
300
|
+
pattern: /node_modules\/\.vite\/deps_ssr\/@vitejs_plugin-rsc_vendor_react-server-dom[^\s)]+/g,
|
|
301
|
+
replacement: "<react-server-dom>"
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
pattern: /node_modules\/\.vite\/deps_ssr\/@vitejs_plugin-rsc_vendor[^\s)]+/g,
|
|
305
|
+
replacement: "<rsc-vendor>"
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
pattern: /node_modules\/\.vite\/deps_ssr\/[^\s)]+/g,
|
|
309
|
+
replacement: "<vite-dep>"
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
pattern: /node_modules\/\.vite\/deps\/[^\s)]+/g,
|
|
313
|
+
replacement: "<vite-dep>"
|
|
314
|
+
}
|
|
315
|
+
];
|
|
316
|
+
/**
|
|
317
|
+
* Patterns that identify Vite-mangled export names in error messages.
|
|
318
|
+
*/
|
|
319
|
+
var MANGLED_NAME_PATTERNS = [{
|
|
320
|
+
pattern: /__vite_ssr_export_default__/g,
|
|
321
|
+
replacement: "<default export>"
|
|
322
|
+
}, {
|
|
323
|
+
pattern: /__vite_ssr_export_(\w+)__/g,
|
|
324
|
+
replacement: "<export $1>"
|
|
325
|
+
}];
|
|
326
|
+
/**
|
|
327
|
+
* Rewrite an error's message and stack to replace internal Vite paths
|
|
328
|
+
* and mangled names with human-readable labels.
|
|
329
|
+
*/
|
|
330
|
+
function formatSsrError(error) {
|
|
331
|
+
if (!(error instanceof Error)) return String(error);
|
|
332
|
+
let message = error.message;
|
|
333
|
+
let stack = error.stack ?? "";
|
|
334
|
+
for (const { pattern, replacement } of MANGLED_NAME_PATTERNS) message = message.replace(pattern, replacement);
|
|
335
|
+
for (const { pattern, replacement } of VENDOR_PATH_PATTERNS) stack = stack.replace(pattern, replacement);
|
|
336
|
+
for (const { pattern, replacement } of MANGLED_NAME_PATTERNS) stack = stack.replace(pattern, replacement);
|
|
337
|
+
const hint = extractErrorHint(error.message);
|
|
338
|
+
const parts = [];
|
|
339
|
+
parts.push(message);
|
|
340
|
+
if (hint) parts.push(` → ${hint}`);
|
|
341
|
+
const userFrames = extractUserFrames(stack);
|
|
342
|
+
if (userFrames.length > 0) {
|
|
343
|
+
parts.push("");
|
|
344
|
+
parts.push(" User code in stack:");
|
|
345
|
+
for (const frame of userFrames) parts.push(` ${frame}`);
|
|
346
|
+
}
|
|
347
|
+
return parts.join("\n");
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Extract a human-readable hint from common React/RSC error messages.
|
|
351
|
+
*
|
|
352
|
+
* React error messages contain useful information but the surrounding
|
|
353
|
+
* context (vendor paths, mangled names) obscures it. This extracts the
|
|
354
|
+
* actionable part as a one-line hint.
|
|
355
|
+
*/
|
|
356
|
+
function extractErrorHint(message) {
|
|
357
|
+
if (message.match(/Functions cannot be passed directly to Client Components/)) {
|
|
358
|
+
const propMatch = message.match(/<[^>]*?\s(\w+)=\{function/);
|
|
359
|
+
if (propMatch) return `Prop "${propMatch[1]}" is a function — mark it "use server" or call it before passing`;
|
|
360
|
+
return "A function prop was passed to a Client Component — mark it \"use server\" or call it before passing";
|
|
361
|
+
}
|
|
362
|
+
if (message.includes("Objects are not valid as a React child")) return "An object was rendered as JSX children — convert to string or extract the value";
|
|
363
|
+
const nullRefMatch = message.match(/Cannot read propert(?:y|ies) of (undefined|null) \(reading '(\w+)'\)/);
|
|
364
|
+
if (nullRefMatch) return `Accessed .${nullRefMatch[2]} on ${nullRefMatch[1]} — check that the value exists`;
|
|
365
|
+
const notFnMatch = message.match(/(\w+) is not a function/);
|
|
366
|
+
if (notFnMatch) return `"${notFnMatch[1]}" is not a function — check imports and exports`;
|
|
367
|
+
if (message.includes("Element type is invalid")) return "A component resolved to undefined/null — check default exports and import paths";
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Extract stack frames that reference user code (not node_modules,
|
|
372
|
+
* not framework internals).
|
|
373
|
+
*
|
|
374
|
+
* Returns at most 5 frames to keep output concise.
|
|
375
|
+
*/
|
|
376
|
+
function extractUserFrames(stack) {
|
|
377
|
+
const lines = stack.split("\n");
|
|
378
|
+
const userFrames = [];
|
|
379
|
+
for (const line of lines) {
|
|
380
|
+
const trimmed = line.trim();
|
|
381
|
+
if (!trimmed.startsWith("at ")) continue;
|
|
382
|
+
if (trimmed.includes("node_modules") || trimmed.includes("<react-server-dom>") || trimmed.includes("<rsc-vendor>") || trimmed.includes("<vite-dep>") || trimmed.includes("node:internal")) continue;
|
|
383
|
+
userFrames.push(trimmed);
|
|
384
|
+
if (userFrames.length >= 5) break;
|
|
385
|
+
}
|
|
386
|
+
return userFrames;
|
|
387
|
+
}
|
|
388
|
+
//#endregion
|
|
389
|
+
//#region src/server/logger.ts
|
|
390
|
+
/**
|
|
391
|
+
* Logger — structured logging with environment-aware formatting.
|
|
392
|
+
*
|
|
393
|
+
* timber.js does not ship a logger. Users export any object with
|
|
394
|
+
* info/warn/error/debug methods from instrumentation.ts and the framework
|
|
395
|
+
* picks it up. Silent if no logger export is present.
|
|
396
|
+
*
|
|
397
|
+
* See design/17-logging.md §"Production Logging"
|
|
398
|
+
*/
|
|
399
|
+
var _logger = null;
|
|
400
|
+
/**
|
|
401
|
+
* Set the user-provided logger. Called by the instrumentation loader
|
|
402
|
+
* when it finds a `logger` export in instrumentation.ts.
|
|
403
|
+
*/
|
|
404
|
+
function setLogger(logger) {
|
|
405
|
+
_logger = logger;
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Get the current logger, or null if none configured.
|
|
409
|
+
* Framework-internal — used at framework event points to emit structured logs.
|
|
410
|
+
*/
|
|
411
|
+
function getLogger() {
|
|
412
|
+
return _logger;
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Inject trace_id and span_id into log data for log–trace correlation.
|
|
416
|
+
* Always injects trace_id (never undefined). Injects span_id only when OTEL is active.
|
|
417
|
+
*/
|
|
418
|
+
function withTraceContext(data) {
|
|
419
|
+
const store = getTraceStore();
|
|
420
|
+
const enriched = { ...data };
|
|
421
|
+
if (store) {
|
|
422
|
+
enriched.trace_id = store.traceId;
|
|
423
|
+
if (store.spanId) enriched.span_id = store.spanId;
|
|
424
|
+
}
|
|
425
|
+
return enriched;
|
|
426
|
+
}
|
|
427
|
+
/** Log a completed request. Level: info. */
|
|
428
|
+
function logRequestCompleted(data) {
|
|
429
|
+
_logger?.info("request completed", withTraceContext(data));
|
|
430
|
+
}
|
|
431
|
+
/** Log request received. Level: debug. */
|
|
432
|
+
function logRequestReceived(data) {
|
|
433
|
+
_logger?.debug("request received", withTraceContext(data));
|
|
434
|
+
}
|
|
435
|
+
/** Log a slow request warning. Level: warn. */
|
|
436
|
+
function logSlowRequest(data) {
|
|
437
|
+
_logger?.warn("slow request exceeded threshold", withTraceContext(data));
|
|
438
|
+
}
|
|
439
|
+
/** Log middleware short-circuit. Level: debug. */
|
|
440
|
+
function logMiddlewareShortCircuit(data) {
|
|
441
|
+
_logger?.debug("middleware short-circuited", withTraceContext(data));
|
|
442
|
+
}
|
|
443
|
+
/** Log unhandled error in middleware phase. Level: error. */
|
|
444
|
+
function logMiddlewareError(data) {
|
|
445
|
+
if (_logger) _logger.error("unhandled error in middleware phase", withTraceContext(data));
|
|
446
|
+
else if (process.env.NODE_ENV !== "production") console.error("[timber] middleware error", data.error);
|
|
447
|
+
}
|
|
448
|
+
/** Log unhandled render-phase error. Level: error. */
|
|
449
|
+
function logRenderError(data) {
|
|
450
|
+
if (_logger) _logger.error("unhandled render-phase error", withTraceContext(data));
|
|
451
|
+
else if (process.env.NODE_ENV !== "production") console.error("[timber] render error:", formatSsrError(data.error));
|
|
452
|
+
}
|
|
453
|
+
/** Log proxy.ts uncaught error. Level: error. */
|
|
454
|
+
function logProxyError(data) {
|
|
455
|
+
if (_logger) _logger.error("proxy.ts threw uncaught error", withTraceContext(data));
|
|
456
|
+
else if (process.env.NODE_ENV !== "production") console.error("[timber] proxy error", data.error);
|
|
457
|
+
}
|
|
458
|
+
/** Log waitUntil() adapter missing (once at startup). Level: warn. */
|
|
459
|
+
function logWaitUntilUnsupported() {
|
|
460
|
+
_logger?.warn("adapter does not support waitUntil()");
|
|
461
|
+
}
|
|
462
|
+
/** Log waitUntil() promise rejection. Level: warn. */
|
|
463
|
+
function logWaitUntilRejected(data) {
|
|
464
|
+
_logger?.warn("waitUntil() promise rejected", withTraceContext(data));
|
|
465
|
+
}
|
|
466
|
+
/** Log staleWhileRevalidate refetch failure. Level: warn. */
|
|
467
|
+
function logSwrRefetchFailed(data) {
|
|
468
|
+
_logger?.warn("staleWhileRevalidate refetch failed", withTraceContext(data));
|
|
469
|
+
}
|
|
470
|
+
/** Log cache miss. Level: debug. */
|
|
471
|
+
function logCacheMiss(data) {
|
|
472
|
+
_logger?.debug("timber.cache MISS", withTraceContext(data));
|
|
473
|
+
}
|
|
474
|
+
//#endregion
|
|
475
|
+
//#region src/server/instrumentation.ts
|
|
476
|
+
/**
|
|
477
|
+
* Instrumentation — loads and runs the user's instrumentation.ts file.
|
|
478
|
+
*
|
|
479
|
+
* instrumentation.ts is a file convention at the project root that exports:
|
|
480
|
+
* - register() — called once at server startup, before the first request
|
|
481
|
+
* - onRequestError() — called for every unhandled server error
|
|
482
|
+
* - logger — any object with info/warn/error/debug methods
|
|
483
|
+
*
|
|
484
|
+
* See design/17-logging.md §"instrumentation.ts — The Entry Point"
|
|
485
|
+
*/
|
|
486
|
+
var _initialized = false;
|
|
487
|
+
var _onRequestError = null;
|
|
488
|
+
/**
|
|
489
|
+
* Load and initialize the user's instrumentation.ts module.
|
|
490
|
+
*
|
|
491
|
+
* - Awaits register() before returning (server blocks on this).
|
|
492
|
+
* - Picks up the logger export and wires it into the framework logger.
|
|
493
|
+
* - Stores onRequestError for later invocation.
|
|
494
|
+
*
|
|
495
|
+
* @param loader - Function that dynamically imports the user's instrumentation module.
|
|
496
|
+
* Returns null if no instrumentation.ts exists.
|
|
497
|
+
*/
|
|
498
|
+
async function loadInstrumentation(loader) {
|
|
499
|
+
if (_initialized) return;
|
|
500
|
+
_initialized = true;
|
|
501
|
+
let mod;
|
|
502
|
+
try {
|
|
503
|
+
mod = await loader();
|
|
504
|
+
} catch (error) {
|
|
505
|
+
console.error("[timber] Failed to load instrumentation.ts:", error);
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
if (!mod) return;
|
|
509
|
+
if (mod.logger && typeof mod.logger.info === "function") setLogger(mod.logger);
|
|
510
|
+
if (typeof mod.onRequestError === "function") _onRequestError = mod.onRequestError;
|
|
511
|
+
if (typeof mod.register === "function") try {
|
|
512
|
+
await mod.register();
|
|
513
|
+
} catch (error) {
|
|
514
|
+
console.error("[timber] instrumentation.ts register() threw:", error);
|
|
515
|
+
throw error;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Call the user's onRequestError hook. Catches and logs any errors thrown
|
|
520
|
+
* by the hook itself — it must not affect the response.
|
|
521
|
+
*/
|
|
522
|
+
async function callOnRequestError(error, request, context) {
|
|
523
|
+
if (!_onRequestError) return;
|
|
524
|
+
try {
|
|
525
|
+
await _onRequestError(error, request, context);
|
|
526
|
+
} catch (hookError) {
|
|
527
|
+
console.error("[timber] onRequestError hook threw:", hookError);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Check if onRequestError is registered.
|
|
532
|
+
*/
|
|
533
|
+
function hasOnRequestError() {
|
|
534
|
+
return _onRequestError !== null;
|
|
535
|
+
}
|
|
536
|
+
//#endregion
|
|
537
|
+
//#region src/server/pipeline.ts
|
|
538
|
+
/**
|
|
539
|
+
* Request pipeline — the central dispatch for all timber.js requests.
|
|
540
|
+
*
|
|
541
|
+
* Pipeline stages (in order):
|
|
542
|
+
* proxy.ts → canonicalize → route match → 103 Early Hints → middleware.ts → render
|
|
543
|
+
*
|
|
544
|
+
* Each stage is a pure function or returns a Response to short-circuit.
|
|
545
|
+
* Each request gets a trace ID, structured logging, and OTEL spans.
|
|
546
|
+
*
|
|
547
|
+
* See design/07-routing.md §"Request Lifecycle", design/02-rendering-pipeline.md §"Request Flow",
|
|
548
|
+
* and design/17-logging.md §"Production Logging"
|
|
549
|
+
*/
|
|
550
|
+
/**
|
|
551
|
+
* Create the request handler from a pipeline configuration.
|
|
552
|
+
*
|
|
553
|
+
* Returns a function that processes an incoming Request through all pipeline stages
|
|
554
|
+
* and produces a Response. This is the top-level entry point for the server.
|
|
555
|
+
*/
|
|
556
|
+
function createPipeline(config) {
|
|
557
|
+
const { proxy, matchRoute, render, earlyHints, stripTrailingSlash = true, slowRequestMs = 3e3, onPipelineError } = config;
|
|
558
|
+
return async (req) => {
|
|
559
|
+
const url = new URL(req.url);
|
|
560
|
+
const method = req.method;
|
|
561
|
+
const path = url.pathname;
|
|
562
|
+
const startTime = performance.now();
|
|
563
|
+
return runWithTraceId(generateTraceId(), async () => {
|
|
564
|
+
return runWithRequestContext(req, async () => {
|
|
565
|
+
logRequestReceived({
|
|
566
|
+
method,
|
|
567
|
+
path
|
|
568
|
+
});
|
|
569
|
+
const response = await withSpan("http.server.request", {
|
|
570
|
+
"http.request.method": method,
|
|
571
|
+
"url.path": path
|
|
572
|
+
}, async () => {
|
|
573
|
+
const otelIds = await getOtelTraceId();
|
|
574
|
+
if (otelIds) replaceTraceId(otelIds.traceId, otelIds.spanId);
|
|
575
|
+
let result;
|
|
576
|
+
if (proxy || config.proxyLoader) result = await runProxyPhase(req, method, path);
|
|
577
|
+
else result = await handleRequest(req, method, path);
|
|
578
|
+
await setSpanAttribute("http.response.status_code", result.status);
|
|
579
|
+
return result;
|
|
580
|
+
});
|
|
581
|
+
const durationMs = Math.round(performance.now() - startTime);
|
|
582
|
+
const status = response.status;
|
|
583
|
+
logRequestCompleted({
|
|
584
|
+
method,
|
|
585
|
+
path,
|
|
586
|
+
status,
|
|
587
|
+
durationMs
|
|
588
|
+
});
|
|
589
|
+
if (slowRequestMs > 0 && durationMs > slowRequestMs) logSlowRequest({
|
|
590
|
+
method,
|
|
591
|
+
path,
|
|
592
|
+
durationMs,
|
|
593
|
+
threshold: slowRequestMs
|
|
594
|
+
});
|
|
595
|
+
return response;
|
|
596
|
+
});
|
|
597
|
+
});
|
|
598
|
+
};
|
|
599
|
+
async function runProxyPhase(req, method, path) {
|
|
600
|
+
try {
|
|
601
|
+
let proxyExport;
|
|
602
|
+
if (config.proxyLoader) proxyExport = (await config.proxyLoader()).default;
|
|
603
|
+
else proxyExport = config.proxy;
|
|
604
|
+
return await withSpan("timber.proxy", {}, () => runProxy(proxyExport, req, () => handleRequest(req, method, path)));
|
|
605
|
+
} catch (error) {
|
|
606
|
+
logProxyError({ error });
|
|
607
|
+
await fireOnRequestError(error, req, "proxy");
|
|
608
|
+
if (onPipelineError && error instanceof Error) onPipelineError(error, "proxy");
|
|
609
|
+
return new Response(null, { status: 500 });
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
async function handleRequest(req, method, path) {
|
|
613
|
+
const result = canonicalize(new URL(req.url).pathname, stripTrailingSlash);
|
|
614
|
+
if (!result.ok) return new Response(null, { status: result.status });
|
|
615
|
+
const canonicalPathname = result.pathname;
|
|
616
|
+
if (config.matchMetadataRoute) {
|
|
617
|
+
const metaMatch = config.matchMetadataRoute(canonicalPathname);
|
|
618
|
+
if (metaMatch) try {
|
|
619
|
+
const mod = await metaMatch.file.load();
|
|
620
|
+
if (typeof mod.default !== "function") return new Response("Metadata route must export a default function", { status: 500 });
|
|
621
|
+
const handlerResult = await mod.default();
|
|
622
|
+
if (handlerResult instanceof Response) return handlerResult;
|
|
623
|
+
const contentType = metaMatch.contentType;
|
|
624
|
+
let body;
|
|
625
|
+
if (typeof handlerResult === "string") body = handlerResult;
|
|
626
|
+
else if (contentType === "application/xml") body = serializeSitemap(handlerResult);
|
|
627
|
+
else if (contentType === "application/manifest+json") body = JSON.stringify(handlerResult, null, 2);
|
|
628
|
+
else body = typeof handlerResult === "string" ? handlerResult : String(handlerResult);
|
|
629
|
+
return new Response(body, {
|
|
630
|
+
status: 200,
|
|
631
|
+
headers: { "Content-Type": `${contentType}; charset=utf-8` }
|
|
632
|
+
});
|
|
633
|
+
} catch (error) {
|
|
634
|
+
logRenderError({
|
|
635
|
+
method,
|
|
636
|
+
path,
|
|
637
|
+
error
|
|
638
|
+
});
|
|
639
|
+
if (onPipelineError && error instanceof Error) onPipelineError(error, "metadata-route");
|
|
640
|
+
return new Response(null, { status: 500 });
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
let match = matchRoute(canonicalPathname);
|
|
644
|
+
let interception;
|
|
645
|
+
const sourceUrl = req.headers.get("X-Timber-URL");
|
|
646
|
+
if (sourceUrl && config.interceptionRewrites?.length) {
|
|
647
|
+
const intercepted = findInterceptionMatch(canonicalPathname, sourceUrl, config.interceptionRewrites);
|
|
648
|
+
if (intercepted) {
|
|
649
|
+
const sourceMatch = matchRoute(intercepted.sourcePathname);
|
|
650
|
+
if (sourceMatch) {
|
|
651
|
+
match = sourceMatch;
|
|
652
|
+
interception = { targetPathname: canonicalPathname };
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
if (!match) {
|
|
657
|
+
if (config.renderNoMatch) {
|
|
658
|
+
const responseHeaders = new Headers();
|
|
659
|
+
return config.renderNoMatch(req, responseHeaders);
|
|
660
|
+
}
|
|
661
|
+
return new Response(null, { status: 404 });
|
|
662
|
+
}
|
|
663
|
+
const responseHeaders = new Headers();
|
|
664
|
+
const requestHeaderOverlay = new Headers();
|
|
665
|
+
if (earlyHints) try {
|
|
666
|
+
await earlyHints(match, req, responseHeaders);
|
|
667
|
+
} catch {}
|
|
668
|
+
if (match.middleware) {
|
|
669
|
+
const ctx = {
|
|
670
|
+
req,
|
|
671
|
+
requestHeaders: requestHeaderOverlay,
|
|
672
|
+
headers: responseHeaders,
|
|
673
|
+
params: match.params,
|
|
674
|
+
searchParams: new URL(req.url).searchParams,
|
|
675
|
+
earlyHints: (hints) => {
|
|
676
|
+
for (const hint of hints) {
|
|
677
|
+
let value = `<${hint.href}>; rel=${hint.rel}`;
|
|
678
|
+
if (hint.as !== void 0) value += `; as=${hint.as}`;
|
|
679
|
+
if (hint.crossOrigin !== void 0) value += `; crossorigin=${hint.crossOrigin}`;
|
|
680
|
+
if (hint.fetchPriority !== void 0) value += `; fetchpriority=${hint.fetchPriority}`;
|
|
681
|
+
responseHeaders.append("Link", value);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
};
|
|
685
|
+
try {
|
|
686
|
+
setMutableCookieContext(true);
|
|
687
|
+
const middlewareResponse = await withSpan("timber.middleware", {}, () => runMiddleware(match.middleware, ctx));
|
|
688
|
+
setMutableCookieContext(false);
|
|
689
|
+
if (middlewareResponse) {
|
|
690
|
+
applyCookieJar(middlewareResponse.headers);
|
|
691
|
+
logMiddlewareShortCircuit({
|
|
692
|
+
method,
|
|
693
|
+
path,
|
|
694
|
+
status: middlewareResponse.status
|
|
695
|
+
});
|
|
696
|
+
return middlewareResponse;
|
|
697
|
+
}
|
|
698
|
+
applyRequestHeaderOverlay(requestHeaderOverlay);
|
|
699
|
+
} catch (error) {
|
|
700
|
+
setMutableCookieContext(false);
|
|
701
|
+
if (error instanceof RedirectSignal) {
|
|
702
|
+
applyCookieJar(responseHeaders);
|
|
703
|
+
if ((req.headers.get("Accept") ?? "").includes("text/x-component")) {
|
|
704
|
+
responseHeaders.set("X-Timber-Redirect", error.location);
|
|
705
|
+
return new Response(null, {
|
|
706
|
+
status: 204,
|
|
707
|
+
headers: responseHeaders
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
responseHeaders.set("Location", error.location);
|
|
711
|
+
return new Response(null, {
|
|
712
|
+
status: error.status,
|
|
713
|
+
headers: responseHeaders
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
if (error instanceof DenySignal) return new Response(null, { status: error.status });
|
|
717
|
+
logMiddlewareError({
|
|
718
|
+
method,
|
|
719
|
+
path,
|
|
720
|
+
error
|
|
721
|
+
});
|
|
722
|
+
await fireOnRequestError(error, req, "handler");
|
|
723
|
+
if (onPipelineError && error instanceof Error) onPipelineError(error, "middleware");
|
|
724
|
+
return new Response(null, { status: 500 });
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
applyCookieJar(responseHeaders);
|
|
728
|
+
try {
|
|
729
|
+
const response = await withSpan("timber.render", { "http.route": canonicalPathname }, () => render(req, match, responseHeaders, requestHeaderOverlay, interception));
|
|
730
|
+
markResponseFlushed();
|
|
731
|
+
return response;
|
|
732
|
+
} catch (error) {
|
|
733
|
+
logRenderError({
|
|
734
|
+
method,
|
|
735
|
+
path,
|
|
736
|
+
error
|
|
737
|
+
});
|
|
738
|
+
await fireOnRequestError(error, req, "render");
|
|
739
|
+
if (onPipelineError && error instanceof Error) onPipelineError(error, "render");
|
|
740
|
+
return new Response(null, { status: 500 });
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* Fire the user's onRequestError hook with request context.
|
|
746
|
+
* Extracts request info from the Request object and calls the instrumentation hook.
|
|
747
|
+
*/
|
|
748
|
+
async function fireOnRequestError(error, req, phase) {
|
|
749
|
+
const url = new URL(req.url);
|
|
750
|
+
const headersObj = {};
|
|
751
|
+
req.headers.forEach((v, k) => {
|
|
752
|
+
headersObj[k] = v;
|
|
753
|
+
});
|
|
754
|
+
await callOnRequestError(error, {
|
|
755
|
+
method: req.method,
|
|
756
|
+
path: url.pathname,
|
|
757
|
+
headers: headersObj
|
|
758
|
+
}, {
|
|
759
|
+
phase,
|
|
760
|
+
routePath: url.pathname,
|
|
761
|
+
routeType: "page",
|
|
762
|
+
traceId: traceId()
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* Check if an intercepting route applies for this soft navigation.
|
|
767
|
+
*
|
|
768
|
+
* Matches the target pathname against interception rewrites, constrained
|
|
769
|
+
* by the source URL (X-Timber-URL header — where the user navigates FROM).
|
|
770
|
+
*
|
|
771
|
+
* Returns the source pathname to re-match if interception applies, or null.
|
|
772
|
+
*/
|
|
773
|
+
function findInterceptionMatch(targetPathname, sourceUrl, rewrites) {
|
|
774
|
+
for (const rewrite of rewrites) {
|
|
775
|
+
if (!sourceUrl.startsWith(rewrite.interceptingPrefix)) continue;
|
|
776
|
+
if (pathnameMatchesPattern(targetPathname, rewrite.interceptedPattern)) return { sourcePathname: rewrite.interceptingPrefix };
|
|
777
|
+
}
|
|
778
|
+
return null;
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* Check if a pathname matches a URL pattern with dynamic segments.
|
|
782
|
+
*
|
|
783
|
+
* Supports [param] (single segment) and [...param] (one or more segments).
|
|
784
|
+
* Static segments must match exactly.
|
|
785
|
+
*/
|
|
786
|
+
function pathnameMatchesPattern(pathname, pattern) {
|
|
787
|
+
const pathParts = pathname === "/" ? [] : pathname.slice(1).split("/");
|
|
788
|
+
const patternParts = pattern === "/" ? [] : pattern.slice(1).split("/");
|
|
789
|
+
let pi = 0;
|
|
790
|
+
for (let i = 0; i < patternParts.length; i++) {
|
|
791
|
+
const segment = patternParts[i];
|
|
792
|
+
if (segment.startsWith("[...") || segment.startsWith("[[...")) return pi < pathParts.length || segment.startsWith("[[...");
|
|
793
|
+
if (segment.startsWith("[") && segment.endsWith("]")) {
|
|
794
|
+
if (pi >= pathParts.length) return false;
|
|
795
|
+
pi++;
|
|
796
|
+
continue;
|
|
797
|
+
}
|
|
798
|
+
if (pi >= pathParts.length || pathParts[pi] !== segment) return false;
|
|
799
|
+
pi++;
|
|
800
|
+
}
|
|
801
|
+
return pi === pathParts.length;
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* Apply all Set-Cookie headers from the cookie jar to a Headers object.
|
|
805
|
+
* Each cookie gets its own Set-Cookie header per RFC 6265 §4.1.
|
|
806
|
+
*/
|
|
807
|
+
function applyCookieJar(headers) {
|
|
808
|
+
for (const value of getSetCookieHeaders()) headers.append("Set-Cookie", value);
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* Serialize a sitemap array to XML.
|
|
812
|
+
* Follows the sitemap.org protocol: https://www.sitemaps.org/protocol.html
|
|
813
|
+
*/
|
|
814
|
+
function serializeSitemap(entries) {
|
|
815
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${entries.map((e) => {
|
|
816
|
+
let xml = ` <url>\n <loc>${escapeXml(e.url)}</loc>`;
|
|
817
|
+
if (e.lastModified) {
|
|
818
|
+
const date = e.lastModified instanceof Date ? e.lastModified.toISOString() : e.lastModified;
|
|
819
|
+
xml += `\n <lastmod>${escapeXml(date)}</lastmod>`;
|
|
820
|
+
}
|
|
821
|
+
if (e.changeFrequency) xml += `\n <changefreq>${escapeXml(e.changeFrequency)}</changefreq>`;
|
|
822
|
+
if (e.priority !== void 0) xml += `\n <priority>${e.priority}</priority>`;
|
|
823
|
+
xml += "\n </url>";
|
|
824
|
+
return xml;
|
|
825
|
+
}).join("\n")}\n</urlset>`;
|
|
826
|
+
}
|
|
827
|
+
/** Escape special XML characters. */
|
|
828
|
+
function escapeXml(str) {
|
|
829
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
830
|
+
}
|
|
831
|
+
//#endregion
|
|
832
|
+
//#region src/server/build-manifest.ts
|
|
833
|
+
/**
|
|
834
|
+
* Collect all CSS files needed for a matched route's segment chain.
|
|
835
|
+
*
|
|
836
|
+
* Walks segments root → leaf, collecting CSS for each layout and page.
|
|
837
|
+
* Deduplicates while preserving order (root layout CSS first).
|
|
838
|
+
*/
|
|
839
|
+
function collectRouteCss(segments, manifest) {
|
|
840
|
+
const seen = /* @__PURE__ */ new Set();
|
|
841
|
+
const result = [];
|
|
842
|
+
for (const segment of segments) for (const file of [segment.layout, segment.page]) {
|
|
843
|
+
if (!file) continue;
|
|
844
|
+
const cssFiles = manifest.css[file.filePath];
|
|
845
|
+
if (!cssFiles) continue;
|
|
846
|
+
for (const url of cssFiles) if (!seen.has(url)) {
|
|
847
|
+
seen.add(url);
|
|
848
|
+
result.push(url);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
return result;
|
|
852
|
+
}
|
|
853
|
+
/**
|
|
854
|
+
* Collect all font entries needed for a matched route's segment chain.
|
|
855
|
+
*
|
|
856
|
+
* Walks segments root → leaf, collecting fonts for each layout and page.
|
|
857
|
+
* Deduplicates by href while preserving order.
|
|
858
|
+
*/
|
|
859
|
+
function collectRouteFonts(segments, manifest) {
|
|
860
|
+
const seen = /* @__PURE__ */ new Set();
|
|
861
|
+
const result = [];
|
|
862
|
+
for (const segment of segments) for (const file of [segment.layout, segment.page]) {
|
|
863
|
+
if (!file) continue;
|
|
864
|
+
const fonts = manifest.fonts[file.filePath];
|
|
865
|
+
if (!fonts) continue;
|
|
866
|
+
for (const entry of fonts) if (!seen.has(entry.href)) {
|
|
867
|
+
seen.add(entry.href);
|
|
868
|
+
result.push(entry);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
return result;
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* Collect modulepreload URLs for a matched route's segment chain.
|
|
875
|
+
*
|
|
876
|
+
* Walks segments root → leaf, collecting transitive JS dependencies
|
|
877
|
+
* for each layout and page. Deduplicates across segments.
|
|
878
|
+
*/
|
|
879
|
+
function collectRouteModulepreloads(segments, manifest) {
|
|
880
|
+
const seen = /* @__PURE__ */ new Set();
|
|
881
|
+
const result = [];
|
|
882
|
+
for (const segment of segments) for (const file of [segment.layout, segment.page]) {
|
|
883
|
+
if (!file) continue;
|
|
884
|
+
const preloads = manifest.modulepreload[file.filePath];
|
|
885
|
+
if (!preloads) continue;
|
|
886
|
+
for (const url of preloads) if (!seen.has(url)) {
|
|
887
|
+
seen.add(url);
|
|
888
|
+
result.push(url);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
return result;
|
|
892
|
+
}
|
|
893
|
+
//#endregion
|
|
894
|
+
//#region src/server/early-hints.ts
|
|
895
|
+
/**
|
|
896
|
+
* 103 Early Hints utilities.
|
|
897
|
+
*
|
|
898
|
+
* Early Hints are sent before the final response to let the browser
|
|
899
|
+
* start fetching critical resources (CSS, fonts, JS) while the server
|
|
900
|
+
* is still rendering.
|
|
901
|
+
*
|
|
902
|
+
* The framework collects hints from two sources:
|
|
903
|
+
* 1. Build manifest — CSS, fonts, and JS chunks known at route-match time
|
|
904
|
+
* 2. ctx.earlyHints() — explicit hints added by middleware or route handlers
|
|
905
|
+
*
|
|
906
|
+
* Both are emitted as Link headers. Cloudflare CDN automatically converts
|
|
907
|
+
* Link headers into 103 Early Hints responses.
|
|
908
|
+
*
|
|
909
|
+
* Design docs: 02-rendering-pipeline.md §"Early Hints (103)"
|
|
910
|
+
*/
|
|
911
|
+
/**
|
|
912
|
+
* Format a single EarlyHint as a Link header value.
|
|
913
|
+
*
|
|
914
|
+
* Examples:
|
|
915
|
+
* `</styles/root.css>; rel=preload; as=style`
|
|
916
|
+
* `</fonts/inter.woff2>; rel=preload; as=font; crossorigin=anonymous`
|
|
917
|
+
* `</_timber/client.js>; rel=modulepreload`
|
|
918
|
+
* `<https://fonts.googleapis.com>; rel=preconnect`
|
|
919
|
+
*/
|
|
920
|
+
function formatLinkHeader(hint) {
|
|
921
|
+
let value = `<${hint.href}>; rel=${hint.rel}`;
|
|
922
|
+
if (hint.as !== void 0) value += `; as=${hint.as}`;
|
|
923
|
+
if (hint.crossOrigin !== void 0) value += `; crossorigin=${hint.crossOrigin}`;
|
|
924
|
+
if (hint.fetchPriority !== void 0) value += `; fetchpriority=${hint.fetchPriority}`;
|
|
925
|
+
return value;
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* Collect all Link header strings for a matched route's segment chain.
|
|
929
|
+
*
|
|
930
|
+
* Walks the build manifest to emit hints for:
|
|
931
|
+
* - CSS stylesheets (rel=preload; as=style)
|
|
932
|
+
* - Font assets (rel=preload; as=font; crossorigin)
|
|
933
|
+
* - JS modulepreload hints (rel=modulepreload) — unless skipJs is set
|
|
934
|
+
*
|
|
935
|
+
* Also emits global CSS from the `_global` manifest key. Route files
|
|
936
|
+
* are server components that don't appear in the client bundle, so
|
|
937
|
+
* per-route CSS keying doesn't work with the RSC plugin. The `_global`
|
|
938
|
+
* key contains all CSS assets from the client build — fine for early
|
|
939
|
+
* hints since they're just prefetch signals.
|
|
940
|
+
*
|
|
941
|
+
* Returns formatted Link header strings, deduplicated, root → leaf order.
|
|
942
|
+
* Returns an empty array in dev mode (manifest is empty).
|
|
943
|
+
*/
|
|
944
|
+
function collectEarlyHintHeaders(segments, manifest, options) {
|
|
945
|
+
const result = [];
|
|
946
|
+
const seen = /* @__PURE__ */ new Set();
|
|
947
|
+
const add = (header) => {
|
|
948
|
+
if (!seen.has(header)) {
|
|
949
|
+
seen.add(header);
|
|
950
|
+
result.push(header);
|
|
951
|
+
}
|
|
952
|
+
};
|
|
953
|
+
for (const url of collectRouteCss(segments, manifest)) add(formatLinkHeader({
|
|
954
|
+
href: url,
|
|
955
|
+
rel: "preload",
|
|
956
|
+
as: "style"
|
|
957
|
+
}));
|
|
958
|
+
for (const url of manifest.css["_global"] ?? []) add(formatLinkHeader({
|
|
959
|
+
href: url,
|
|
960
|
+
rel: "preload",
|
|
961
|
+
as: "style"
|
|
962
|
+
}));
|
|
963
|
+
for (const font of collectRouteFonts(segments, manifest)) add(formatLinkHeader({
|
|
964
|
+
href: font.href,
|
|
965
|
+
rel: "preload",
|
|
966
|
+
as: "font",
|
|
967
|
+
crossOrigin: "anonymous"
|
|
968
|
+
}));
|
|
969
|
+
if (!options?.skipJs) for (const url of collectRouteModulepreloads(segments, manifest)) add(formatLinkHeader({
|
|
970
|
+
href: url,
|
|
971
|
+
rel: "modulepreload"
|
|
972
|
+
}));
|
|
973
|
+
return result;
|
|
974
|
+
}
|
|
975
|
+
//#endregion
|
|
976
|
+
//#region src/server/early-hints-sender.ts
|
|
977
|
+
/**
|
|
978
|
+
* Per-request 103 Early Hints sender — ALS bridge for platform adapters.
|
|
979
|
+
*
|
|
980
|
+
* The pipeline collects Link headers for CSS, fonts, and JS chunks at
|
|
981
|
+
* route-match time. On platforms that support it (Node.js v18.11+, Bun),
|
|
982
|
+
* the adapter can send these as a 103 Early Hints interim response before
|
|
983
|
+
* the final response is ready.
|
|
984
|
+
*
|
|
985
|
+
* This module provides an ALS-based bridge: the generated entry point
|
|
986
|
+
* (e.g., the Nitro entry) wraps the handler with `runWithEarlyHintsSender`,
|
|
987
|
+
* binding a per-request sender function. The pipeline calls
|
|
988
|
+
* `sendEarlyHints103()` to fire the 103 if a sender is available.
|
|
989
|
+
*
|
|
990
|
+
* On platforms where 103 is handled at the CDN level (e.g., Cloudflare
|
|
991
|
+
* converts Link headers into 103 automatically), no sender is installed
|
|
992
|
+
* and `sendEarlyHints103()` is a no-op.
|
|
993
|
+
*
|
|
994
|
+
* Design doc: 02-rendering-pipeline.md §"Early Hints (103)"
|
|
995
|
+
*/
|
|
996
|
+
var earlyHintsSenderAls = new AsyncLocalStorage();
|
|
997
|
+
/**
|
|
998
|
+
* Run a function with a per-request early hints sender installed.
|
|
999
|
+
*
|
|
1000
|
+
* Called by generated entry points (e.g., Nitro node-server/bun) to
|
|
1001
|
+
* bind the platform's writeEarlyHints capability for the request duration.
|
|
1002
|
+
*/
|
|
1003
|
+
function runWithEarlyHintsSender(sender, fn) {
|
|
1004
|
+
return earlyHintsSenderAls.run(sender, fn);
|
|
1005
|
+
}
|
|
1006
|
+
/**
|
|
1007
|
+
* Send collected Link headers as a 103 Early Hints response.
|
|
1008
|
+
*
|
|
1009
|
+
* No-op if no sender is installed for the current request (e.g., on
|
|
1010
|
+
* Cloudflare where the CDN handles 103 automatically, or in dev mode).
|
|
1011
|
+
*
|
|
1012
|
+
* Non-fatal: errors from the sender are caught and silently ignored.
|
|
1013
|
+
*/
|
|
1014
|
+
function sendEarlyHints103(links) {
|
|
1015
|
+
if (!links.length) return;
|
|
1016
|
+
const sender = earlyHintsSenderAls.getStore();
|
|
1017
|
+
if (!sender) return;
|
|
1018
|
+
try {
|
|
1019
|
+
sender(links);
|
|
1020
|
+
} catch {}
|
|
1021
|
+
}
|
|
1022
|
+
//#endregion
|
|
1023
|
+
//#region src/server/tree-builder.ts
|
|
1024
|
+
/**
|
|
1025
|
+
* Build the unified element tree from a matched segment chain.
|
|
1026
|
+
*
|
|
1027
|
+
* Construction is bottom-up:
|
|
1028
|
+
* 1. Start with the page component (leaf segment)
|
|
1029
|
+
* 2. Wrap in status-code error boundaries (fallback chain)
|
|
1030
|
+
* 3. Wrap in AccessGate (if segment has access.ts)
|
|
1031
|
+
* 4. Pass as children to the segment's layout
|
|
1032
|
+
* 5. Repeat up the segment chain to root
|
|
1033
|
+
*
|
|
1034
|
+
* Parallel slots are resolved at each layout level and composed as named props.
|
|
1035
|
+
*/
|
|
1036
|
+
async function buildElementTree(config) {
|
|
1037
|
+
const { segments, params, searchParams, loadModule, createElement } = config;
|
|
1038
|
+
if (segments.length === 0) throw new Error("[timber] buildElementTree: empty segment chain");
|
|
1039
|
+
const leaf = segments[segments.length - 1];
|
|
1040
|
+
if (leaf.route && !leaf.page) return {
|
|
1041
|
+
tree: null,
|
|
1042
|
+
isApiRoute: true
|
|
1043
|
+
};
|
|
1044
|
+
const PageComponent = (leaf.page ? await loadModule(leaf.page) : null)?.default;
|
|
1045
|
+
if (!PageComponent) throw new Error(`[timber] No page component found for route at ${leaf.urlPath}. Each route must have a page.tsx or route.ts.`);
|
|
1046
|
+
let element = createElement(PageComponent, {
|
|
1047
|
+
params,
|
|
1048
|
+
searchParams
|
|
1049
|
+
});
|
|
1050
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
1051
|
+
const segment = segments[i];
|
|
1052
|
+
element = await wrapWithErrorBoundaries(segment, element, loadModule, createElement);
|
|
1053
|
+
if (segment.access) {
|
|
1054
|
+
const accessFn = (await loadModule(segment.access)).default;
|
|
1055
|
+
element = createElement("timber:access-gate", {
|
|
1056
|
+
accessFn,
|
|
1057
|
+
params,
|
|
1058
|
+
searchParams,
|
|
1059
|
+
segmentName: segment.segmentName,
|
|
1060
|
+
children: element
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
if (segment.layout) {
|
|
1064
|
+
const LayoutComponent = (await loadModule(segment.layout)).default;
|
|
1065
|
+
if (LayoutComponent) {
|
|
1066
|
+
const slotProps = {};
|
|
1067
|
+
if (segment.slots.size > 0) for (const [slotName, slotNode] of segment.slots) slotProps[slotName] = await buildSlotElement(slotNode, params, searchParams, loadModule, createElement);
|
|
1068
|
+
element = createElement(LayoutComponent, {
|
|
1069
|
+
...slotProps,
|
|
1070
|
+
params,
|
|
1071
|
+
searchParams,
|
|
1072
|
+
children: element
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
return {
|
|
1078
|
+
tree: element,
|
|
1079
|
+
isApiRoute: false
|
|
1080
|
+
};
|
|
1081
|
+
}
|
|
1082
|
+
/**
|
|
1083
|
+
* Build the element tree for a parallel slot.
|
|
1084
|
+
*
|
|
1085
|
+
* Slots have their own access.ts (SlotAccessGate) and error boundaries.
|
|
1086
|
+
* On access denial: denied.tsx → default.tsx → null (graceful degradation).
|
|
1087
|
+
*/
|
|
1088
|
+
async function buildSlotElement(slotNode, params, searchParams, loadModule, createElement) {
|
|
1089
|
+
const PageComponent = (slotNode.page ? await loadModule(slotNode.page) : null)?.default;
|
|
1090
|
+
const DefaultComponent = (slotNode.default ? await loadModule(slotNode.default) : null)?.default;
|
|
1091
|
+
if (!PageComponent) return DefaultComponent ? createElement(DefaultComponent, {
|
|
1092
|
+
params,
|
|
1093
|
+
searchParams
|
|
1094
|
+
}) : null;
|
|
1095
|
+
let element = createElement(PageComponent, {
|
|
1096
|
+
params,
|
|
1097
|
+
searchParams
|
|
1098
|
+
});
|
|
1099
|
+
element = await wrapWithErrorBoundaries(slotNode, element, loadModule, createElement);
|
|
1100
|
+
if (slotNode.access) {
|
|
1101
|
+
const accessFn = (await loadModule(slotNode.access)).default;
|
|
1102
|
+
const DeniedComponent = (slotNode.denied ? await loadModule(slotNode.denied) : null)?.default;
|
|
1103
|
+
element = createElement("timber:slot-access-gate", {
|
|
1104
|
+
accessFn,
|
|
1105
|
+
params,
|
|
1106
|
+
searchParams,
|
|
1107
|
+
deniedFallback: DeniedComponent ? createElement(DeniedComponent, {
|
|
1108
|
+
slot: slotNode.segmentName.replace(/^@/, ""),
|
|
1109
|
+
dangerouslyPassData: void 0
|
|
1110
|
+
}) : null,
|
|
1111
|
+
defaultFallback: DefaultComponent ? createElement(DefaultComponent, {
|
|
1112
|
+
params,
|
|
1113
|
+
searchParams
|
|
1114
|
+
}) : null,
|
|
1115
|
+
children: element
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
return element;
|
|
1119
|
+
}
|
|
1120
|
+
/**
|
|
1121
|
+
* Wrap an element with error boundaries from a segment's status-code files.
|
|
1122
|
+
*
|
|
1123
|
+
* Wrapping order (innermost to outermost):
|
|
1124
|
+
* 1. Specific status files (503.tsx, 429.tsx, etc.)
|
|
1125
|
+
* 2. Category catch-alls (4xx.tsx, 5xx.tsx)
|
|
1126
|
+
* 3. error.tsx (general error boundary)
|
|
1127
|
+
*
|
|
1128
|
+
* This creates the fallback chain described in design/10-error-handling.md.
|
|
1129
|
+
*/
|
|
1130
|
+
async function wrapWithErrorBoundaries(segment, element, loadModule, createElement) {
|
|
1131
|
+
if (segment.statusFiles) {
|
|
1132
|
+
for (const [key, file] of segment.statusFiles) if (key !== "4xx" && key !== "5xx") {
|
|
1133
|
+
const status = parseInt(key, 10);
|
|
1134
|
+
if (!isNaN(status)) {
|
|
1135
|
+
const Component = (await loadModule(file)).default;
|
|
1136
|
+
if (Component) element = createElement(TimberErrorBoundary, {
|
|
1137
|
+
fallbackComponent: Component,
|
|
1138
|
+
status,
|
|
1139
|
+
children: element
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
for (const [key, file] of segment.statusFiles) if (key === "4xx" || key === "5xx") {
|
|
1144
|
+
const Component = (await loadModule(file)).default;
|
|
1145
|
+
if (Component) element = createElement(TimberErrorBoundary, {
|
|
1146
|
+
fallbackComponent: Component,
|
|
1147
|
+
status: key === "4xx" ? 400 : 500,
|
|
1148
|
+
children: element
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
if (segment.error) {
|
|
1153
|
+
const ErrorComponent = (await loadModule(segment.error)).default;
|
|
1154
|
+
if (ErrorComponent) element = createElement(TimberErrorBoundary, {
|
|
1155
|
+
fallbackComponent: ErrorComponent,
|
|
1156
|
+
children: element
|
|
1157
|
+
});
|
|
1158
|
+
}
|
|
1159
|
+
return element;
|
|
1160
|
+
}
|
|
1161
|
+
//#endregion
|
|
1162
|
+
//#region src/server/access-gate.tsx
|
|
1163
|
+
/**
|
|
1164
|
+
* AccessGate and SlotAccessGate — framework-injected async server components.
|
|
1165
|
+
*
|
|
1166
|
+
* AccessGate wraps each segment's layout in the element tree. It calls the
|
|
1167
|
+
* segment's access.ts before the layout renders. If access.ts calls deny()
|
|
1168
|
+
* or redirect(), the signal propagates as a render-phase throw — caught by
|
|
1169
|
+
* the flush controller to produce the correct HTTP status code.
|
|
1170
|
+
*
|
|
1171
|
+
* SlotAccessGate wraps parallel slot content. On denial, it renders the
|
|
1172
|
+
* graceful degradation chain: denied.tsx → default.tsx → null. Slot denial
|
|
1173
|
+
* does not affect the HTTP status code.
|
|
1174
|
+
*
|
|
1175
|
+
* See design/04-authorization.md and design/02-rendering-pipeline.md §"AccessGate"
|
|
1176
|
+
*/
|
|
1177
|
+
/**
|
|
1178
|
+
* Framework-injected access gate for segments.
|
|
1179
|
+
*
|
|
1180
|
+
* When a pre-computed `verdict` prop is provided (from the pre-render pass
|
|
1181
|
+
* in route-element-builder.ts), AccessGate replays it synchronously — no
|
|
1182
|
+
* async, no re-execution of access.ts, immune to Suspense timing. The OTEL
|
|
1183
|
+
* span was already emitted during the pre-render pass.
|
|
1184
|
+
*
|
|
1185
|
+
* When no verdict is provided (backward compat with tree-builder.ts),
|
|
1186
|
+
* AccessGate calls accessFn directly with OTEL instrumentation.
|
|
1187
|
+
*
|
|
1188
|
+
* access.ts is a pure gate — return values are discarded. The layout below
|
|
1189
|
+
* gets the same data by calling the same cached functions (React.cache dedup).
|
|
1190
|
+
*/
|
|
1191
|
+
function AccessGate(props) {
|
|
1192
|
+
const { accessFn, params, searchParams, segmentName, verdict, children } = props;
|
|
1193
|
+
if (verdict !== void 0) {
|
|
1194
|
+
if (verdict === "pass") return children;
|
|
1195
|
+
throw verdict;
|
|
1196
|
+
}
|
|
1197
|
+
return accessGateFallback(accessFn, params, searchParams, segmentName, children);
|
|
1198
|
+
}
|
|
1199
|
+
/**
|
|
1200
|
+
* Async fallback for AccessGate when no pre-computed verdict is available.
|
|
1201
|
+
* Calls accessFn with OTEL instrumentation.
|
|
1202
|
+
*/
|
|
1203
|
+
async function accessGateFallback(accessFn, params, searchParams, segmentName, children) {
|
|
1204
|
+
await withSpan("timber.access", { "timber.segment": segmentName ?? "unknown" }, async () => {
|
|
1205
|
+
try {
|
|
1206
|
+
await accessFn({
|
|
1207
|
+
params,
|
|
1208
|
+
searchParams
|
|
1209
|
+
});
|
|
1210
|
+
await setSpanAttribute("timber.result", "pass");
|
|
1211
|
+
} catch (error) {
|
|
1212
|
+
if (error instanceof DenySignal) {
|
|
1213
|
+
await setSpanAttribute("timber.result", "deny");
|
|
1214
|
+
await setSpanAttribute("timber.deny_status", error.status);
|
|
1215
|
+
if (error.sourceFile) await setSpanAttribute("timber.deny_file", error.sourceFile);
|
|
1216
|
+
} else if (error instanceof RedirectSignal) await setSpanAttribute("timber.result", "redirect");
|
|
1217
|
+
throw error;
|
|
1218
|
+
}
|
|
1219
|
+
});
|
|
1220
|
+
return children;
|
|
1221
|
+
}
|
|
1222
|
+
/**
|
|
1223
|
+
* Framework-injected access gate for parallel slots.
|
|
1224
|
+
*
|
|
1225
|
+
* On denial, graceful degradation: denied.tsx → default.tsx → null.
|
|
1226
|
+
* The HTTP status code is unaffected — slot denial is a UI concern, not
|
|
1227
|
+
* a protocol concern. The parent layout and sibling slots still render.
|
|
1228
|
+
*
|
|
1229
|
+
* redirect() in slot access.ts is a dev-mode error — redirecting from a
|
|
1230
|
+
* slot doesn't make architectural sense.
|
|
1231
|
+
*/
|
|
1232
|
+
async function SlotAccessGate(props) {
|
|
1233
|
+
const { accessFn, params, searchParams, deniedFallback, defaultFallback, children } = props;
|
|
1234
|
+
try {
|
|
1235
|
+
await accessFn({
|
|
1236
|
+
params,
|
|
1237
|
+
searchParams
|
|
1238
|
+
});
|
|
1239
|
+
} catch (error) {
|
|
1240
|
+
if (error instanceof DenySignal) return deniedFallback ?? defaultFallback ?? null;
|
|
1241
|
+
if (error instanceof RedirectSignal) {
|
|
1242
|
+
if (process.env.NODE_ENV !== "production") console.error("[timber] redirect() is not allowed in slot access.ts. Slots use deny() for graceful degradation — denied.tsx → default.tsx → null. If you need to redirect, move the logic to the parent segment's access.ts.");
|
|
1243
|
+
return deniedFallback ?? defaultFallback ?? null;
|
|
1244
|
+
}
|
|
1245
|
+
if (process.env.NODE_ENV !== "production") console.warn("[timber] Unhandled error in slot access.ts. Use deny() for access control, not unhandled throws.", error);
|
|
1246
|
+
throw error;
|
|
1247
|
+
}
|
|
1248
|
+
return children;
|
|
1249
|
+
}
|
|
1250
|
+
//#endregion
|
|
1251
|
+
//#region src/server/status-code-resolver.ts
|
|
1252
|
+
/**
|
|
1253
|
+
* Maps legacy file convention names to their corresponding HTTP status codes.
|
|
1254
|
+
* Only used in the 4xx component fallback chain.
|
|
1255
|
+
*/
|
|
1256
|
+
var LEGACY_FILE_TO_STATUS = {
|
|
1257
|
+
"not-found": 404,
|
|
1258
|
+
"forbidden": 403,
|
|
1259
|
+
"unauthorized": 401
|
|
1260
|
+
};
|
|
1261
|
+
/**
|
|
1262
|
+
* Resolve the status-code file to render for a given HTTP status code.
|
|
1263
|
+
*
|
|
1264
|
+
* Walks the segment chain from leaf to root following the fallback chain
|
|
1265
|
+
* defined in design/10-error-handling.md. Returns null if no file is found
|
|
1266
|
+
* (caller should render the framework default).
|
|
1267
|
+
*
|
|
1268
|
+
* @param status - The HTTP status code (4xx or 5xx).
|
|
1269
|
+
* @param segments - The matched segment chain from root (index 0) to leaf (last).
|
|
1270
|
+
* @param format - The response format family ('component' or 'json'). Defaults to 'component'.
|
|
1271
|
+
*/
|
|
1272
|
+
function resolveStatusFile(status, segments, format = "component") {
|
|
1273
|
+
if (status >= 400 && status <= 499) return format === "json" ? resolve4xxJson(status, segments) : resolve4xx(status, segments);
|
|
1274
|
+
if (status >= 500 && status <= 599) return format === "json" ? resolve5xxJson(status, segments) : resolve5xx(status, segments);
|
|
1275
|
+
return null;
|
|
1276
|
+
}
|
|
1277
|
+
/**
|
|
1278
|
+
* 4xx component fallback chain (three separate passes):
|
|
1279
|
+
* Pass 1 — status files (leaf → root): {status}.tsx → 4xx.tsx
|
|
1280
|
+
* Pass 2 — legacy compat (leaf → root): not-found.tsx / forbidden.tsx / unauthorized.tsx
|
|
1281
|
+
* Pass 3 — error.tsx (leaf → root)
|
|
1282
|
+
*/
|
|
1283
|
+
function resolve4xx(status, segments) {
|
|
1284
|
+
const statusStr = String(status);
|
|
1285
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
1286
|
+
const segment = segments[i];
|
|
1287
|
+
if (!segment.statusFiles) continue;
|
|
1288
|
+
const exact = segment.statusFiles.get(statusStr);
|
|
1289
|
+
if (exact) return {
|
|
1290
|
+
file: exact,
|
|
1291
|
+
status,
|
|
1292
|
+
kind: "exact",
|
|
1293
|
+
segmentIndex: i
|
|
1294
|
+
};
|
|
1295
|
+
const category = segment.statusFiles.get("4xx");
|
|
1296
|
+
if (category) return {
|
|
1297
|
+
file: category,
|
|
1298
|
+
status,
|
|
1299
|
+
kind: "category",
|
|
1300
|
+
segmentIndex: i
|
|
1301
|
+
};
|
|
1302
|
+
}
|
|
1303
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
1304
|
+
const segment = segments[i];
|
|
1305
|
+
if (!segment.legacyStatusFiles) continue;
|
|
1306
|
+
for (const [name, legacyStatus] of Object.entries(LEGACY_FILE_TO_STATUS)) if (legacyStatus === status) {
|
|
1307
|
+
const file = segment.legacyStatusFiles.get(name);
|
|
1308
|
+
if (file) return {
|
|
1309
|
+
file,
|
|
1310
|
+
status,
|
|
1311
|
+
kind: "legacy",
|
|
1312
|
+
segmentIndex: i
|
|
1313
|
+
};
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
for (let i = segments.length - 1; i >= 0; i--) if (segments[i].error) return {
|
|
1317
|
+
file: segments[i].error,
|
|
1318
|
+
status,
|
|
1319
|
+
kind: "error",
|
|
1320
|
+
segmentIndex: i
|
|
1321
|
+
};
|
|
1322
|
+
return null;
|
|
1323
|
+
}
|
|
1324
|
+
/**
|
|
1325
|
+
* 4xx JSON fallback chain (single pass):
|
|
1326
|
+
* Pass 1 — json status files (leaf → root): {status}.json → 4xx.json
|
|
1327
|
+
* No legacy compat, no error.tsx — JSON chain terminates at category catch-all.
|
|
1328
|
+
*/
|
|
1329
|
+
function resolve4xxJson(status, segments) {
|
|
1330
|
+
const statusStr = String(status);
|
|
1331
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
1332
|
+
const segment = segments[i];
|
|
1333
|
+
if (!segment.jsonStatusFiles) continue;
|
|
1334
|
+
const exact = segment.jsonStatusFiles.get(statusStr);
|
|
1335
|
+
if (exact) return {
|
|
1336
|
+
file: exact,
|
|
1337
|
+
status,
|
|
1338
|
+
kind: "exact",
|
|
1339
|
+
segmentIndex: i
|
|
1340
|
+
};
|
|
1341
|
+
const category = segment.jsonStatusFiles.get("4xx");
|
|
1342
|
+
if (category) return {
|
|
1343
|
+
file: category,
|
|
1344
|
+
status,
|
|
1345
|
+
kind: "category",
|
|
1346
|
+
segmentIndex: i
|
|
1347
|
+
};
|
|
1348
|
+
}
|
|
1349
|
+
return null;
|
|
1350
|
+
}
|
|
1351
|
+
/**
|
|
1352
|
+
* 5xx component fallback chain (single pass, per-segment):
|
|
1353
|
+
* At each segment (leaf → root): {status}.tsx → 5xx.tsx → error.tsx
|
|
1354
|
+
*/
|
|
1355
|
+
function resolve5xx(status, segments) {
|
|
1356
|
+
const statusStr = String(status);
|
|
1357
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
1358
|
+
const segment = segments[i];
|
|
1359
|
+
if (segment.statusFiles) {
|
|
1360
|
+
const exact = segment.statusFiles.get(statusStr);
|
|
1361
|
+
if (exact) return {
|
|
1362
|
+
file: exact,
|
|
1363
|
+
status,
|
|
1364
|
+
kind: "exact",
|
|
1365
|
+
segmentIndex: i
|
|
1366
|
+
};
|
|
1367
|
+
const category = segment.statusFiles.get("5xx");
|
|
1368
|
+
if (category) return {
|
|
1369
|
+
file: category,
|
|
1370
|
+
status,
|
|
1371
|
+
kind: "category",
|
|
1372
|
+
segmentIndex: i
|
|
1373
|
+
};
|
|
1374
|
+
}
|
|
1375
|
+
if (segment.error) return {
|
|
1376
|
+
file: segment.error,
|
|
1377
|
+
status,
|
|
1378
|
+
kind: "error",
|
|
1379
|
+
segmentIndex: i
|
|
1380
|
+
};
|
|
1381
|
+
}
|
|
1382
|
+
return null;
|
|
1383
|
+
}
|
|
1384
|
+
/**
|
|
1385
|
+
* 5xx JSON fallback chain (single pass):
|
|
1386
|
+
* At each segment (leaf → root): {status}.json → 5xx.json
|
|
1387
|
+
* No error.tsx equivalent — JSON chain terminates at category catch-all.
|
|
1388
|
+
*/
|
|
1389
|
+
function resolve5xxJson(status, segments) {
|
|
1390
|
+
const statusStr = String(status);
|
|
1391
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
1392
|
+
const segment = segments[i];
|
|
1393
|
+
if (!segment.jsonStatusFiles) continue;
|
|
1394
|
+
const exact = segment.jsonStatusFiles.get(statusStr);
|
|
1395
|
+
if (exact) return {
|
|
1396
|
+
file: exact,
|
|
1397
|
+
status,
|
|
1398
|
+
kind: "exact",
|
|
1399
|
+
segmentIndex: i
|
|
1400
|
+
};
|
|
1401
|
+
const category = segment.jsonStatusFiles.get("5xx");
|
|
1402
|
+
if (category) return {
|
|
1403
|
+
file: category,
|
|
1404
|
+
status,
|
|
1405
|
+
kind: "category",
|
|
1406
|
+
segmentIndex: i
|
|
1407
|
+
};
|
|
1408
|
+
}
|
|
1409
|
+
return null;
|
|
1410
|
+
}
|
|
1411
|
+
/**
|
|
1412
|
+
* Resolve the denial file for a parallel route slot.
|
|
1413
|
+
*
|
|
1414
|
+
* Slot denial is graceful degradation — no HTTP status on the wire.
|
|
1415
|
+
* Fallback chain: denied.tsx → default.tsx → null.
|
|
1416
|
+
*
|
|
1417
|
+
* @param slotNode - The segment node for the slot (segmentType === 'slot').
|
|
1418
|
+
*/
|
|
1419
|
+
function resolveSlotDenied(slotNode) {
|
|
1420
|
+
const slotName = slotNode.segmentName.replace(/^@/, "");
|
|
1421
|
+
if (slotNode.denied) return {
|
|
1422
|
+
file: slotNode.denied,
|
|
1423
|
+
slotName,
|
|
1424
|
+
kind: "denied"
|
|
1425
|
+
};
|
|
1426
|
+
if (slotNode.default) return {
|
|
1427
|
+
file: slotNode.default,
|
|
1428
|
+
slotName,
|
|
1429
|
+
kind: "default"
|
|
1430
|
+
};
|
|
1431
|
+
return null;
|
|
1432
|
+
}
|
|
1433
|
+
//#endregion
|
|
1434
|
+
//#region src/server/flush.ts
|
|
1435
|
+
/**
|
|
1436
|
+
* Flush controller for timber.js rendering.
|
|
1437
|
+
*
|
|
1438
|
+
* Holds the response until `onShellReady` fires, then commits the HTTP status
|
|
1439
|
+
* code and flushes the shell. Render-phase signals (deny, redirect, unhandled
|
|
1440
|
+
* throws) caught before flush produce correct HTTP status codes.
|
|
1441
|
+
*
|
|
1442
|
+
* See design/02-rendering-pipeline.md §"The Flush Point" and §"The Hold Window"
|
|
1443
|
+
*/
|
|
1444
|
+
/**
|
|
1445
|
+
* Execute a render and hold the response until the shell is ready.
|
|
1446
|
+
*
|
|
1447
|
+
* The flush controller:
|
|
1448
|
+
* 1. Calls the render function to start renderToReadableStream
|
|
1449
|
+
* 2. Waits for shellReady (onShellReady)
|
|
1450
|
+
* 3. If a render-phase signal was thrown (deny, redirect, error), produces
|
|
1451
|
+
* the correct HTTP status code
|
|
1452
|
+
* 4. If the shell rendered successfully, commits the status and streams
|
|
1453
|
+
*
|
|
1454
|
+
* Render-phase signals caught before flush:
|
|
1455
|
+
* - `DenySignal` → HTTP 4xx with appropriate status code
|
|
1456
|
+
* - `RedirectSignal` → HTTP 3xx with Location header
|
|
1457
|
+
* - `RenderError` → HTTP status from error (default 500)
|
|
1458
|
+
* - Unhandled error → HTTP 500
|
|
1459
|
+
*
|
|
1460
|
+
* @param renderFn - Function that starts the React render.
|
|
1461
|
+
* @param options - Flush configuration.
|
|
1462
|
+
* @returns The committed HTTP Response.
|
|
1463
|
+
*/
|
|
1464
|
+
async function flushResponse(renderFn, options = {}) {
|
|
1465
|
+
const { responseHeaders = new Headers(), defaultStatus = 200 } = options;
|
|
1466
|
+
let renderResult;
|
|
1467
|
+
try {
|
|
1468
|
+
renderResult = await renderFn();
|
|
1469
|
+
} catch (error) {
|
|
1470
|
+
return handleSignal(error, responseHeaders);
|
|
1471
|
+
}
|
|
1472
|
+
try {
|
|
1473
|
+
await renderResult.shellReady;
|
|
1474
|
+
} catch (error) {
|
|
1475
|
+
return handleSignal(error, responseHeaders);
|
|
1476
|
+
}
|
|
1477
|
+
responseHeaders.set("Content-Type", "text/html; charset=utf-8");
|
|
1478
|
+
return {
|
|
1479
|
+
response: new Response(renderResult.stream, {
|
|
1480
|
+
status: defaultStatus,
|
|
1481
|
+
headers: responseHeaders
|
|
1482
|
+
}),
|
|
1483
|
+
status: defaultStatus,
|
|
1484
|
+
isRedirect: false,
|
|
1485
|
+
isDenial: false
|
|
1486
|
+
};
|
|
1487
|
+
}
|
|
1488
|
+
/**
|
|
1489
|
+
* Handle a render-phase signal and produce the correct HTTP response.
|
|
1490
|
+
*/
|
|
1491
|
+
function handleSignal(error, responseHeaders) {
|
|
1492
|
+
if (error instanceof RedirectSignal) {
|
|
1493
|
+
responseHeaders.set("Location", error.location);
|
|
1494
|
+
return {
|
|
1495
|
+
response: new Response(null, {
|
|
1496
|
+
status: error.status,
|
|
1497
|
+
headers: responseHeaders
|
|
1498
|
+
}),
|
|
1499
|
+
status: error.status,
|
|
1500
|
+
isRedirect: true,
|
|
1501
|
+
isDenial: false
|
|
1502
|
+
};
|
|
1503
|
+
}
|
|
1504
|
+
if (error instanceof DenySignal) return {
|
|
1505
|
+
response: new Response(null, {
|
|
1506
|
+
status: error.status,
|
|
1507
|
+
headers: responseHeaders
|
|
1508
|
+
}),
|
|
1509
|
+
status: error.status,
|
|
1510
|
+
isRedirect: false,
|
|
1511
|
+
isDenial: true
|
|
1512
|
+
};
|
|
1513
|
+
if (error instanceof RenderError) return {
|
|
1514
|
+
response: new Response(null, {
|
|
1515
|
+
status: error.status,
|
|
1516
|
+
headers: responseHeaders
|
|
1517
|
+
}),
|
|
1518
|
+
status: error.status,
|
|
1519
|
+
isRedirect: false,
|
|
1520
|
+
isDenial: false
|
|
1521
|
+
};
|
|
1522
|
+
console.error("[timber] Unhandled render-phase error:", error);
|
|
1523
|
+
return {
|
|
1524
|
+
response: new Response(null, {
|
|
1525
|
+
status: 500,
|
|
1526
|
+
headers: responseHeaders
|
|
1527
|
+
}),
|
|
1528
|
+
status: 500,
|
|
1529
|
+
isRedirect: false,
|
|
1530
|
+
isDenial: false
|
|
1531
|
+
};
|
|
1532
|
+
}
|
|
1533
|
+
//#endregion
|
|
1534
|
+
//#region src/server/csrf.ts
|
|
1535
|
+
/** HTTP methods that are considered safe (no mutation). */
|
|
1536
|
+
var SAFE_METHODS = new Set([
|
|
1537
|
+
"GET",
|
|
1538
|
+
"HEAD",
|
|
1539
|
+
"OPTIONS"
|
|
1540
|
+
]);
|
|
1541
|
+
/**
|
|
1542
|
+
* Validate the Origin header against the request's Host.
|
|
1543
|
+
*
|
|
1544
|
+
* For mutation methods (POST, PUT, PATCH, DELETE):
|
|
1545
|
+
* - If `csrf: false`, skip validation.
|
|
1546
|
+
* - If `allowedOrigins` is set, Origin must match one exactly (no wildcards).
|
|
1547
|
+
* - Otherwise, Origin's host must match the request's Host header.
|
|
1548
|
+
*
|
|
1549
|
+
* Safe methods (GET, HEAD, OPTIONS) always pass.
|
|
1550
|
+
*/
|
|
1551
|
+
function validateCsrf(req, config) {
|
|
1552
|
+
if (SAFE_METHODS.has(req.method)) return { ok: true };
|
|
1553
|
+
if (config.csrf === false) return { ok: true };
|
|
1554
|
+
const origin = req.headers.get("Origin");
|
|
1555
|
+
if (!origin) return {
|
|
1556
|
+
ok: false,
|
|
1557
|
+
status: 403
|
|
1558
|
+
};
|
|
1559
|
+
if (config.allowedOrigins) return config.allowedOrigins.includes(origin) ? { ok: true } : {
|
|
1560
|
+
ok: false,
|
|
1561
|
+
status: 403
|
|
1562
|
+
};
|
|
1563
|
+
const host = req.headers.get("Host");
|
|
1564
|
+
if (!host) return {
|
|
1565
|
+
ok: false,
|
|
1566
|
+
status: 403
|
|
1567
|
+
};
|
|
1568
|
+
let originHost;
|
|
1569
|
+
try {
|
|
1570
|
+
originHost = new URL(origin).host;
|
|
1571
|
+
} catch {
|
|
1572
|
+
return {
|
|
1573
|
+
ok: false,
|
|
1574
|
+
status: 403
|
|
1575
|
+
};
|
|
1576
|
+
}
|
|
1577
|
+
return originHost === host ? { ok: true } : {
|
|
1578
|
+
ok: false,
|
|
1579
|
+
status: 403
|
|
1580
|
+
};
|
|
1581
|
+
}
|
|
1582
|
+
//#endregion
|
|
1583
|
+
//#region src/server/body-limits.ts
|
|
1584
|
+
var KB = 1024;
|
|
1585
|
+
var MB = 1024 * KB;
|
|
1586
|
+
var GB = 1024 * MB;
|
|
1587
|
+
var DEFAULT_LIMITS = {
|
|
1588
|
+
actionBodySize: 1 * MB,
|
|
1589
|
+
uploadBodySize: 10 * MB,
|
|
1590
|
+
maxFields: 100
|
|
1591
|
+
};
|
|
1592
|
+
var SIZE_PATTERN = /^(\d+(?:\.\d+)?)\s*(kb|mb|gb)?$/i;
|
|
1593
|
+
/** Parse a human-readable size string ("1mb", "512kb", "1024") into bytes. */
|
|
1594
|
+
function parseBodySize(size) {
|
|
1595
|
+
const match = SIZE_PATTERN.exec(size.trim());
|
|
1596
|
+
if (!match) throw new Error(`Invalid body size format: "${size}". Expected format like "1mb", "512kb", or "1024".`);
|
|
1597
|
+
const value = Number.parseFloat(match[1]);
|
|
1598
|
+
const unit = (match[2] ?? "").toLowerCase();
|
|
1599
|
+
switch (unit) {
|
|
1600
|
+
case "kb": return Math.floor(value * KB);
|
|
1601
|
+
case "mb": return Math.floor(value * MB);
|
|
1602
|
+
case "gb": return Math.floor(value * GB);
|
|
1603
|
+
case "": return Math.floor(value);
|
|
1604
|
+
default: throw new Error(`Unknown size unit: "${unit}"`);
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
/** Check whether a request body exceeds the configured size limit (stateless, no ALS). */
|
|
1608
|
+
function enforceBodyLimits(req, kind, config) {
|
|
1609
|
+
const contentLength = req.headers.get("Content-Length");
|
|
1610
|
+
if (!contentLength) return {
|
|
1611
|
+
ok: false,
|
|
1612
|
+
status: 411
|
|
1613
|
+
};
|
|
1614
|
+
const bodySize = Number.parseInt(contentLength, 10);
|
|
1615
|
+
if (Number.isNaN(bodySize)) return {
|
|
1616
|
+
ok: false,
|
|
1617
|
+
status: 411
|
|
1618
|
+
};
|
|
1619
|
+
return bodySize <= resolveLimit(kind, config) ? { ok: true } : {
|
|
1620
|
+
ok: false,
|
|
1621
|
+
status: 413
|
|
1622
|
+
};
|
|
1623
|
+
}
|
|
1624
|
+
/**
|
|
1625
|
+
* Resolve the byte limit for a given body kind, using config overrides or defaults.
|
|
1626
|
+
*/
|
|
1627
|
+
function resolveLimit(kind, config) {
|
|
1628
|
+
const userLimits = config.limits;
|
|
1629
|
+
if (kind === "action") return userLimits?.actionBodySize ? parseBodySize(userLimits.actionBodySize) : DEFAULT_LIMITS.actionBodySize;
|
|
1630
|
+
return userLimits?.uploadBodySize ? parseBodySize(userLimits.uploadBodySize) : DEFAULT_LIMITS.uploadBodySize;
|
|
1631
|
+
}
|
|
1632
|
+
//#endregion
|
|
1633
|
+
//#region src/server/metadata-render.ts
|
|
1634
|
+
/**
|
|
1635
|
+
* Convert resolved metadata into an array of head element descriptors.
|
|
1636
|
+
*
|
|
1637
|
+
* Each descriptor has a `tag` ('title', 'meta', 'link') and either
|
|
1638
|
+
* `content` (for <title>) or `attrs` (for <meta>/<link>).
|
|
1639
|
+
*
|
|
1640
|
+
* The framework's MetadataResolver component consumes these descriptors
|
|
1641
|
+
* and renders them into the <head>.
|
|
1642
|
+
*/
|
|
1643
|
+
function renderMetadataToElements(metadata) {
|
|
1644
|
+
const elements = [];
|
|
1645
|
+
if (typeof metadata.title === "string") elements.push({
|
|
1646
|
+
tag: "title",
|
|
1647
|
+
content: metadata.title
|
|
1648
|
+
});
|
|
1649
|
+
const simpleMetaProps = [
|
|
1650
|
+
["description", metadata.description],
|
|
1651
|
+
["generator", metadata.generator],
|
|
1652
|
+
["application-name", metadata.applicationName],
|
|
1653
|
+
["referrer", metadata.referrer],
|
|
1654
|
+
["category", metadata.category],
|
|
1655
|
+
["creator", metadata.creator],
|
|
1656
|
+
["publisher", metadata.publisher]
|
|
1657
|
+
];
|
|
1658
|
+
for (const [name, content] of simpleMetaProps) if (content) elements.push({
|
|
1659
|
+
tag: "meta",
|
|
1660
|
+
attrs: {
|
|
1661
|
+
name,
|
|
1662
|
+
content
|
|
1663
|
+
}
|
|
1664
|
+
});
|
|
1665
|
+
if (metadata.keywords) {
|
|
1666
|
+
const content = Array.isArray(metadata.keywords) ? metadata.keywords.join(", ") : metadata.keywords;
|
|
1667
|
+
elements.push({
|
|
1668
|
+
tag: "meta",
|
|
1669
|
+
attrs: {
|
|
1670
|
+
name: "keywords",
|
|
1671
|
+
content
|
|
1672
|
+
}
|
|
1673
|
+
});
|
|
1674
|
+
}
|
|
1675
|
+
if (metadata.robots) {
|
|
1676
|
+
const content = typeof metadata.robots === "string" ? metadata.robots : renderRobotsObject(metadata.robots);
|
|
1677
|
+
elements.push({
|
|
1678
|
+
tag: "meta",
|
|
1679
|
+
attrs: {
|
|
1680
|
+
name: "robots",
|
|
1681
|
+
content
|
|
1682
|
+
}
|
|
1683
|
+
});
|
|
1684
|
+
if (typeof metadata.robots === "object" && metadata.robots.googleBot) {
|
|
1685
|
+
const gbContent = typeof metadata.robots.googleBot === "string" ? metadata.robots.googleBot : renderRobotsObject(metadata.robots.googleBot);
|
|
1686
|
+
elements.push({
|
|
1687
|
+
tag: "meta",
|
|
1688
|
+
attrs: {
|
|
1689
|
+
name: "googlebot",
|
|
1690
|
+
content: gbContent
|
|
1691
|
+
}
|
|
1692
|
+
});
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
if (metadata.openGraph) renderOpenGraph(metadata.openGraph, elements);
|
|
1696
|
+
if (metadata.twitter) renderTwitter(metadata.twitter, elements);
|
|
1697
|
+
if (metadata.icons) renderIcons(metadata.icons, elements);
|
|
1698
|
+
if (metadata.manifest) elements.push({
|
|
1699
|
+
tag: "link",
|
|
1700
|
+
attrs: {
|
|
1701
|
+
rel: "manifest",
|
|
1702
|
+
href: metadata.manifest
|
|
1703
|
+
}
|
|
1704
|
+
});
|
|
1705
|
+
if (metadata.alternates) renderAlternates(metadata.alternates, elements);
|
|
1706
|
+
if (metadata.verification) renderVerification(metadata.verification, elements);
|
|
1707
|
+
if (metadata.formatDetection) {
|
|
1708
|
+
const parts = [];
|
|
1709
|
+
if (metadata.formatDetection.telephone === false) parts.push("telephone=no");
|
|
1710
|
+
if (metadata.formatDetection.email === false) parts.push("email=no");
|
|
1711
|
+
if (metadata.formatDetection.address === false) parts.push("address=no");
|
|
1712
|
+
if (parts.length > 0) elements.push({
|
|
1713
|
+
tag: "meta",
|
|
1714
|
+
attrs: {
|
|
1715
|
+
name: "format-detection",
|
|
1716
|
+
content: parts.join(", ")
|
|
1717
|
+
}
|
|
1718
|
+
});
|
|
1719
|
+
}
|
|
1720
|
+
if (metadata.authors) {
|
|
1721
|
+
const authorList = Array.isArray(metadata.authors) ? metadata.authors : [metadata.authors];
|
|
1722
|
+
for (const author of authorList) {
|
|
1723
|
+
if (author.name) elements.push({
|
|
1724
|
+
tag: "meta",
|
|
1725
|
+
attrs: {
|
|
1726
|
+
name: "author",
|
|
1727
|
+
content: author.name
|
|
1728
|
+
}
|
|
1729
|
+
});
|
|
1730
|
+
if (author.url) elements.push({
|
|
1731
|
+
tag: "link",
|
|
1732
|
+
attrs: {
|
|
1733
|
+
rel: "author",
|
|
1734
|
+
href: author.url
|
|
1735
|
+
}
|
|
1736
|
+
});
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
if (metadata.appleWebApp) renderAppleWebApp(metadata.appleWebApp, elements);
|
|
1740
|
+
if (metadata.appLinks) renderAppLinks(metadata.appLinks, elements);
|
|
1741
|
+
if (metadata.itunes) renderItunes(metadata.itunes, elements);
|
|
1742
|
+
if (metadata.other) for (const [name, value] of Object.entries(metadata.other)) {
|
|
1743
|
+
const content = Array.isArray(value) ? value.join(", ") : value;
|
|
1744
|
+
elements.push({
|
|
1745
|
+
tag: "meta",
|
|
1746
|
+
attrs: {
|
|
1747
|
+
name,
|
|
1748
|
+
content
|
|
1749
|
+
}
|
|
1750
|
+
});
|
|
1751
|
+
}
|
|
1752
|
+
return elements;
|
|
1753
|
+
}
|
|
1754
|
+
function renderRobotsObject(robots) {
|
|
1755
|
+
const parts = [];
|
|
1756
|
+
if (robots.index === true) parts.push("index");
|
|
1757
|
+
if (robots.index === false) parts.push("noindex");
|
|
1758
|
+
if (robots.follow === true) parts.push("follow");
|
|
1759
|
+
if (robots.follow === false) parts.push("nofollow");
|
|
1760
|
+
return parts.join(", ");
|
|
1761
|
+
}
|
|
1762
|
+
function renderOpenGraph(og, elements) {
|
|
1763
|
+
const simpleProps = [
|
|
1764
|
+
["og:title", og.title],
|
|
1765
|
+
["og:description", og.description],
|
|
1766
|
+
["og:url", og.url],
|
|
1767
|
+
["og:site_name", og.siteName],
|
|
1768
|
+
["og:locale", og.locale],
|
|
1769
|
+
["og:type", og.type],
|
|
1770
|
+
["og:article:published_time", og.publishedTime],
|
|
1771
|
+
["og:article:modified_time", og.modifiedTime]
|
|
1772
|
+
];
|
|
1773
|
+
for (const [property, content] of simpleProps) if (content) elements.push({
|
|
1774
|
+
tag: "meta",
|
|
1775
|
+
attrs: {
|
|
1776
|
+
property,
|
|
1777
|
+
content
|
|
1778
|
+
}
|
|
1779
|
+
});
|
|
1780
|
+
if (og.images) if (typeof og.images === "string") elements.push({
|
|
1781
|
+
tag: "meta",
|
|
1782
|
+
attrs: {
|
|
1783
|
+
property: "og:image",
|
|
1784
|
+
content: og.images
|
|
1785
|
+
}
|
|
1786
|
+
});
|
|
1787
|
+
else {
|
|
1788
|
+
const imgList = Array.isArray(og.images) ? og.images : [og.images];
|
|
1789
|
+
for (const img of imgList) {
|
|
1790
|
+
elements.push({
|
|
1791
|
+
tag: "meta",
|
|
1792
|
+
attrs: {
|
|
1793
|
+
property: "og:image",
|
|
1794
|
+
content: img.url
|
|
1795
|
+
}
|
|
1796
|
+
});
|
|
1797
|
+
if (img.width) elements.push({
|
|
1798
|
+
tag: "meta",
|
|
1799
|
+
attrs: {
|
|
1800
|
+
property: "og:image:width",
|
|
1801
|
+
content: String(img.width)
|
|
1802
|
+
}
|
|
1803
|
+
});
|
|
1804
|
+
if (img.height) elements.push({
|
|
1805
|
+
tag: "meta",
|
|
1806
|
+
attrs: {
|
|
1807
|
+
property: "og:image:height",
|
|
1808
|
+
content: String(img.height)
|
|
1809
|
+
}
|
|
1810
|
+
});
|
|
1811
|
+
if (img.alt) elements.push({
|
|
1812
|
+
tag: "meta",
|
|
1813
|
+
attrs: {
|
|
1814
|
+
property: "og:image:alt",
|
|
1815
|
+
content: img.alt
|
|
1816
|
+
}
|
|
1817
|
+
});
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
if (og.videos) for (const video of og.videos) elements.push({
|
|
1821
|
+
tag: "meta",
|
|
1822
|
+
attrs: {
|
|
1823
|
+
property: "og:video",
|
|
1824
|
+
content: video.url
|
|
1825
|
+
}
|
|
1826
|
+
});
|
|
1827
|
+
if (og.audio) for (const audio of og.audio) elements.push({
|
|
1828
|
+
tag: "meta",
|
|
1829
|
+
attrs: {
|
|
1830
|
+
property: "og:audio",
|
|
1831
|
+
content: audio.url
|
|
1832
|
+
}
|
|
1833
|
+
});
|
|
1834
|
+
if (og.authors) for (const author of og.authors) elements.push({
|
|
1835
|
+
tag: "meta",
|
|
1836
|
+
attrs: {
|
|
1837
|
+
property: "og:article:author",
|
|
1838
|
+
content: author
|
|
1839
|
+
}
|
|
1840
|
+
});
|
|
1841
|
+
}
|
|
1842
|
+
function renderTwitter(tw, elements) {
|
|
1843
|
+
const simpleProps = [
|
|
1844
|
+
["twitter:card", tw.card],
|
|
1845
|
+
["twitter:site", tw.site],
|
|
1846
|
+
["twitter:site:id", tw.siteId],
|
|
1847
|
+
["twitter:title", tw.title],
|
|
1848
|
+
["twitter:description", tw.description],
|
|
1849
|
+
["twitter:creator", tw.creator],
|
|
1850
|
+
["twitter:creator:id", tw.creatorId]
|
|
1851
|
+
];
|
|
1852
|
+
for (const [name, content] of simpleProps) if (content) elements.push({
|
|
1853
|
+
tag: "meta",
|
|
1854
|
+
attrs: {
|
|
1855
|
+
name,
|
|
1856
|
+
content
|
|
1857
|
+
}
|
|
1858
|
+
});
|
|
1859
|
+
if (tw.images) if (typeof tw.images === "string") elements.push({
|
|
1860
|
+
tag: "meta",
|
|
1861
|
+
attrs: {
|
|
1862
|
+
name: "twitter:image",
|
|
1863
|
+
content: tw.images
|
|
1864
|
+
}
|
|
1865
|
+
});
|
|
1866
|
+
else {
|
|
1867
|
+
const imgList = Array.isArray(tw.images) ? tw.images : [tw.images];
|
|
1868
|
+
for (const img of imgList) {
|
|
1869
|
+
const url = typeof img === "string" ? img : img.url;
|
|
1870
|
+
elements.push({
|
|
1871
|
+
tag: "meta",
|
|
1872
|
+
attrs: {
|
|
1873
|
+
name: "twitter:image",
|
|
1874
|
+
content: url
|
|
1875
|
+
}
|
|
1876
|
+
});
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
if (tw.players) for (const player of tw.players) {
|
|
1880
|
+
elements.push({
|
|
1881
|
+
tag: "meta",
|
|
1882
|
+
attrs: {
|
|
1883
|
+
name: "twitter:player",
|
|
1884
|
+
content: player.playerUrl
|
|
1885
|
+
}
|
|
1886
|
+
});
|
|
1887
|
+
if (player.width) elements.push({
|
|
1888
|
+
tag: "meta",
|
|
1889
|
+
attrs: {
|
|
1890
|
+
name: "twitter:player:width",
|
|
1891
|
+
content: String(player.width)
|
|
1892
|
+
}
|
|
1893
|
+
});
|
|
1894
|
+
if (player.height) elements.push({
|
|
1895
|
+
tag: "meta",
|
|
1896
|
+
attrs: {
|
|
1897
|
+
name: "twitter:player:height",
|
|
1898
|
+
content: String(player.height)
|
|
1899
|
+
}
|
|
1900
|
+
});
|
|
1901
|
+
if (player.streamUrl) elements.push({
|
|
1902
|
+
tag: "meta",
|
|
1903
|
+
attrs: {
|
|
1904
|
+
name: "twitter:player:stream",
|
|
1905
|
+
content: player.streamUrl
|
|
1906
|
+
}
|
|
1907
|
+
});
|
|
1908
|
+
}
|
|
1909
|
+
if (tw.app) {
|
|
1910
|
+
const platforms = [
|
|
1911
|
+
["iPhone", "iphone"],
|
|
1912
|
+
["iPad", "ipad"],
|
|
1913
|
+
["googlePlay", "googleplay"]
|
|
1914
|
+
];
|
|
1915
|
+
if (tw.app.name) {
|
|
1916
|
+
for (const [key, tag] of platforms) if (tw.app.id?.[key]) elements.push({
|
|
1917
|
+
tag: "meta",
|
|
1918
|
+
attrs: {
|
|
1919
|
+
name: `twitter:app:name:${tag}`,
|
|
1920
|
+
content: tw.app.name
|
|
1921
|
+
}
|
|
1922
|
+
});
|
|
1923
|
+
}
|
|
1924
|
+
for (const [key, tag] of platforms) {
|
|
1925
|
+
const id = tw.app.id?.[key];
|
|
1926
|
+
if (id) elements.push({
|
|
1927
|
+
tag: "meta",
|
|
1928
|
+
attrs: {
|
|
1929
|
+
name: `twitter:app:id:${tag}`,
|
|
1930
|
+
content: id
|
|
1931
|
+
}
|
|
1932
|
+
});
|
|
1933
|
+
}
|
|
1934
|
+
for (const [key, tag] of platforms) {
|
|
1935
|
+
const url = tw.app.url?.[key];
|
|
1936
|
+
if (url) elements.push({
|
|
1937
|
+
tag: "meta",
|
|
1938
|
+
attrs: {
|
|
1939
|
+
name: `twitter:app:url:${tag}`,
|
|
1940
|
+
content: url
|
|
1941
|
+
}
|
|
1942
|
+
});
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
function renderIcons(icons, elements) {
|
|
1947
|
+
if (icons.icon) {
|
|
1948
|
+
if (typeof icons.icon === "string") elements.push({
|
|
1949
|
+
tag: "link",
|
|
1950
|
+
attrs: {
|
|
1951
|
+
rel: "icon",
|
|
1952
|
+
href: icons.icon
|
|
1953
|
+
}
|
|
1954
|
+
});
|
|
1955
|
+
else if (Array.isArray(icons.icon)) for (const icon of icons.icon) {
|
|
1956
|
+
const attrs = {
|
|
1957
|
+
rel: "icon",
|
|
1958
|
+
href: icon.url
|
|
1959
|
+
};
|
|
1960
|
+
if (icon.sizes) attrs.sizes = icon.sizes;
|
|
1961
|
+
if (icon.type) attrs.type = icon.type;
|
|
1962
|
+
elements.push({
|
|
1963
|
+
tag: "link",
|
|
1964
|
+
attrs
|
|
1965
|
+
});
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
if (icons.shortcut) {
|
|
1969
|
+
const urls = Array.isArray(icons.shortcut) ? icons.shortcut : [icons.shortcut];
|
|
1970
|
+
for (const url of urls) elements.push({
|
|
1971
|
+
tag: "link",
|
|
1972
|
+
attrs: {
|
|
1973
|
+
rel: "shortcut icon",
|
|
1974
|
+
href: url
|
|
1975
|
+
}
|
|
1976
|
+
});
|
|
1977
|
+
}
|
|
1978
|
+
if (icons.apple) {
|
|
1979
|
+
if (typeof icons.apple === "string") elements.push({
|
|
1980
|
+
tag: "link",
|
|
1981
|
+
attrs: {
|
|
1982
|
+
rel: "apple-touch-icon",
|
|
1983
|
+
href: icons.apple
|
|
1984
|
+
}
|
|
1985
|
+
});
|
|
1986
|
+
else if (Array.isArray(icons.apple)) for (const icon of icons.apple) {
|
|
1987
|
+
const attrs = {
|
|
1988
|
+
rel: "apple-touch-icon",
|
|
1989
|
+
href: icon.url
|
|
1990
|
+
};
|
|
1991
|
+
if (icon.sizes) attrs.sizes = icon.sizes;
|
|
1992
|
+
elements.push({
|
|
1993
|
+
tag: "link",
|
|
1994
|
+
attrs
|
|
1995
|
+
});
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
if (icons.other) for (const icon of icons.other) {
|
|
1999
|
+
const attrs = {
|
|
2000
|
+
rel: icon.rel,
|
|
2001
|
+
href: icon.url
|
|
2002
|
+
};
|
|
2003
|
+
if (icon.sizes) attrs.sizes = icon.sizes;
|
|
2004
|
+
if (icon.type) attrs.type = icon.type;
|
|
2005
|
+
elements.push({
|
|
2006
|
+
tag: "link",
|
|
2007
|
+
attrs
|
|
2008
|
+
});
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
function renderAlternates(alternates, elements) {
|
|
2012
|
+
if (alternates.canonical) elements.push({
|
|
2013
|
+
tag: "link",
|
|
2014
|
+
attrs: {
|
|
2015
|
+
rel: "canonical",
|
|
2016
|
+
href: alternates.canonical
|
|
2017
|
+
}
|
|
2018
|
+
});
|
|
2019
|
+
if (alternates.languages) for (const [lang, href] of Object.entries(alternates.languages)) elements.push({
|
|
2020
|
+
tag: "link",
|
|
2021
|
+
attrs: {
|
|
2022
|
+
rel: "alternate",
|
|
2023
|
+
hreflang: lang,
|
|
2024
|
+
href
|
|
2025
|
+
}
|
|
2026
|
+
});
|
|
2027
|
+
if (alternates.media) for (const [media, href] of Object.entries(alternates.media)) elements.push({
|
|
2028
|
+
tag: "link",
|
|
2029
|
+
attrs: {
|
|
2030
|
+
rel: "alternate",
|
|
2031
|
+
media,
|
|
2032
|
+
href
|
|
2033
|
+
}
|
|
2034
|
+
});
|
|
2035
|
+
if (alternates.types) for (const [type, href] of Object.entries(alternates.types)) elements.push({
|
|
2036
|
+
tag: "link",
|
|
2037
|
+
attrs: {
|
|
2038
|
+
rel: "alternate",
|
|
2039
|
+
type,
|
|
2040
|
+
href
|
|
2041
|
+
}
|
|
2042
|
+
});
|
|
2043
|
+
}
|
|
2044
|
+
function renderVerification(verification, elements) {
|
|
2045
|
+
const verificationProps = [
|
|
2046
|
+
["google-site-verification", verification.google],
|
|
2047
|
+
["y_key", verification.yahoo],
|
|
2048
|
+
["yandex-verification", verification.yandex]
|
|
2049
|
+
];
|
|
2050
|
+
for (const [name, content] of verificationProps) if (content) elements.push({
|
|
2051
|
+
tag: "meta",
|
|
2052
|
+
attrs: {
|
|
2053
|
+
name,
|
|
2054
|
+
content
|
|
2055
|
+
}
|
|
2056
|
+
});
|
|
2057
|
+
if (verification.other) for (const [name, value] of Object.entries(verification.other)) {
|
|
2058
|
+
const content = Array.isArray(value) ? value.join(", ") : value;
|
|
2059
|
+
elements.push({
|
|
2060
|
+
tag: "meta",
|
|
2061
|
+
attrs: {
|
|
2062
|
+
name,
|
|
2063
|
+
content
|
|
2064
|
+
}
|
|
2065
|
+
});
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
2068
|
+
function renderAppleWebApp(appleWebApp, elements) {
|
|
2069
|
+
if (appleWebApp.capable) elements.push({
|
|
2070
|
+
tag: "meta",
|
|
2071
|
+
attrs: {
|
|
2072
|
+
name: "apple-mobile-web-app-capable",
|
|
2073
|
+
content: "yes"
|
|
2074
|
+
}
|
|
2075
|
+
});
|
|
2076
|
+
if (appleWebApp.title) elements.push({
|
|
2077
|
+
tag: "meta",
|
|
2078
|
+
attrs: {
|
|
2079
|
+
name: "apple-mobile-web-app-title",
|
|
2080
|
+
content: appleWebApp.title
|
|
2081
|
+
}
|
|
2082
|
+
});
|
|
2083
|
+
if (appleWebApp.statusBarStyle) elements.push({
|
|
2084
|
+
tag: "meta",
|
|
2085
|
+
attrs: {
|
|
2086
|
+
name: "apple-mobile-web-app-status-bar-style",
|
|
2087
|
+
content: appleWebApp.statusBarStyle
|
|
2088
|
+
}
|
|
2089
|
+
});
|
|
2090
|
+
if (appleWebApp.startupImage) {
|
|
2091
|
+
const images = Array.isArray(appleWebApp.startupImage) ? appleWebApp.startupImage : [{ url: appleWebApp.startupImage }];
|
|
2092
|
+
for (const img of images) {
|
|
2093
|
+
const attrs = {
|
|
2094
|
+
rel: "apple-touch-startup-image",
|
|
2095
|
+
href: typeof img === "string" ? img : img.url
|
|
2096
|
+
};
|
|
2097
|
+
if (typeof img === "object" && img.media) attrs.media = img.media;
|
|
2098
|
+
elements.push({
|
|
2099
|
+
tag: "link",
|
|
2100
|
+
attrs
|
|
2101
|
+
});
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
function renderAppLinks(appLinks, elements) {
|
|
2106
|
+
const platformEntries = [
|
|
2107
|
+
["ios", appLinks.ios],
|
|
2108
|
+
["android", appLinks.android],
|
|
2109
|
+
["windows", appLinks.windows],
|
|
2110
|
+
["windows_phone", appLinks.windowsPhone],
|
|
2111
|
+
["windows_universal", appLinks.windowsUniversal]
|
|
2112
|
+
];
|
|
2113
|
+
for (const [platform, entries] of platformEntries) {
|
|
2114
|
+
if (!entries) continue;
|
|
2115
|
+
for (const entry of entries) for (const [key, value] of Object.entries(entry)) if (value !== void 0 && value !== null) elements.push({
|
|
2116
|
+
tag: "meta",
|
|
2117
|
+
attrs: {
|
|
2118
|
+
property: `al:${platform}:${key}`,
|
|
2119
|
+
content: String(value)
|
|
2120
|
+
}
|
|
2121
|
+
});
|
|
2122
|
+
}
|
|
2123
|
+
if (appLinks.web) {
|
|
2124
|
+
if (appLinks.web.url) elements.push({
|
|
2125
|
+
tag: "meta",
|
|
2126
|
+
attrs: {
|
|
2127
|
+
property: "al:web:url",
|
|
2128
|
+
content: appLinks.web.url
|
|
2129
|
+
}
|
|
2130
|
+
});
|
|
2131
|
+
if (appLinks.web.shouldFallback !== void 0) elements.push({
|
|
2132
|
+
tag: "meta",
|
|
2133
|
+
attrs: {
|
|
2134
|
+
property: "al:web:should_fallback",
|
|
2135
|
+
content: appLinks.web.shouldFallback ? "true" : "false"
|
|
2136
|
+
}
|
|
2137
|
+
});
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
function renderItunes(itunes, elements) {
|
|
2141
|
+
const parts = [`app-id=${itunes.appId}`];
|
|
2142
|
+
if (itunes.affiliateData) parts.push(`affiliate-data=${itunes.affiliateData}`);
|
|
2143
|
+
if (itunes.appArgument) parts.push(`app-argument=${itunes.appArgument}`);
|
|
2144
|
+
elements.push({
|
|
2145
|
+
tag: "meta",
|
|
2146
|
+
attrs: {
|
|
2147
|
+
name: "apple-itunes-app",
|
|
2148
|
+
content: parts.join(", ")
|
|
2149
|
+
}
|
|
2150
|
+
});
|
|
2151
|
+
}
|
|
2152
|
+
//#endregion
|
|
2153
|
+
//#region src/server/metadata.ts
|
|
2154
|
+
/**
|
|
2155
|
+
* Resolve a title value with an optional template.
|
|
2156
|
+
*
|
|
2157
|
+
* - string → apply template if present
|
|
2158
|
+
* - { absolute: '...' } → use as-is, skip template
|
|
2159
|
+
* - { default: '...' } → use as fallback (no template applied)
|
|
2160
|
+
* - undefined → undefined
|
|
2161
|
+
*/
|
|
2162
|
+
function resolveTitle(title, template) {
|
|
2163
|
+
if (title === void 0 || title === null) return;
|
|
2164
|
+
if (typeof title === "string") return template ? template.replace("%s", title) : title;
|
|
2165
|
+
if (title.absolute !== void 0) return title.absolute;
|
|
2166
|
+
if (title.default !== void 0) return title.default;
|
|
2167
|
+
}
|
|
2168
|
+
/**
|
|
2169
|
+
* Resolve metadata from a segment chain.
|
|
2170
|
+
*
|
|
2171
|
+
* Processes entries from root layout to page (in segment order).
|
|
2172
|
+
* The merge algorithm:
|
|
2173
|
+
* 1. Shallow-merge all keys except title (later wins)
|
|
2174
|
+
* 2. Track the most recent title template
|
|
2175
|
+
* 3. Resolve the final title using the template
|
|
2176
|
+
*
|
|
2177
|
+
* In error state, the page entry is dropped and noindex is injected.
|
|
2178
|
+
*
|
|
2179
|
+
* See design/16-metadata.md §"Merge Algorithm"
|
|
2180
|
+
*/
|
|
2181
|
+
function resolveMetadata(entries, options = {}) {
|
|
2182
|
+
const { errorState = false } = options;
|
|
2183
|
+
const merged = {};
|
|
2184
|
+
let titleTemplate;
|
|
2185
|
+
let lastDefault;
|
|
2186
|
+
let rawTitle;
|
|
2187
|
+
for (const { metadata, isPage } of entries) {
|
|
2188
|
+
if (errorState && isPage) continue;
|
|
2189
|
+
if (metadata.title !== void 0 && typeof metadata.title === "object") {
|
|
2190
|
+
if (metadata.title.template !== void 0) titleTemplate = metadata.title.template;
|
|
2191
|
+
if (metadata.title.default !== void 0) lastDefault = metadata.title.default;
|
|
2192
|
+
}
|
|
2193
|
+
for (const key of Object.keys(metadata)) {
|
|
2194
|
+
if (key === "title") continue;
|
|
2195
|
+
merged[key] = metadata[key];
|
|
2196
|
+
}
|
|
2197
|
+
if (metadata.title !== void 0) rawTitle = metadata.title;
|
|
2198
|
+
}
|
|
2199
|
+
if (errorState) {
|
|
2200
|
+
rawTitle = lastDefault !== void 0 ? { default: lastDefault } : rawTitle;
|
|
2201
|
+
titleTemplate = void 0;
|
|
2202
|
+
}
|
|
2203
|
+
const resolvedTitle = resolveTitle(rawTitle, titleTemplate);
|
|
2204
|
+
if (resolvedTitle !== void 0) merged.title = resolvedTitle;
|
|
2205
|
+
if (errorState) merged.robots = "noindex";
|
|
2206
|
+
return merged;
|
|
2207
|
+
}
|
|
2208
|
+
/**
|
|
2209
|
+
* Check if a string is an absolute URL.
|
|
2210
|
+
*/
|
|
2211
|
+
function isAbsoluteUrl(url) {
|
|
2212
|
+
return url.startsWith("http://") || url.startsWith("https://") || url.startsWith("//");
|
|
2213
|
+
}
|
|
2214
|
+
/**
|
|
2215
|
+
* Resolve a relative URL against a base URL.
|
|
2216
|
+
*/
|
|
2217
|
+
function resolveUrl(url, base) {
|
|
2218
|
+
if (isAbsoluteUrl(url)) return url;
|
|
2219
|
+
return new URL(url, base).toString();
|
|
2220
|
+
}
|
|
2221
|
+
/**
|
|
2222
|
+
* Resolve relative URLs in metadata fields against metadataBase.
|
|
2223
|
+
*
|
|
2224
|
+
* Returns a new metadata object with URLs resolved. Absolute URLs are not modified.
|
|
2225
|
+
* If metadataBase is not set, returns the metadata unchanged.
|
|
2226
|
+
*/
|
|
2227
|
+
function resolveMetadataUrls(metadata) {
|
|
2228
|
+
const base = metadata.metadataBase;
|
|
2229
|
+
if (!base) return metadata;
|
|
2230
|
+
const result = { ...metadata };
|
|
2231
|
+
if (result.openGraph) {
|
|
2232
|
+
result.openGraph = { ...result.openGraph };
|
|
2233
|
+
if (typeof result.openGraph.images === "string") result.openGraph.images = resolveUrl(result.openGraph.images, base);
|
|
2234
|
+
else if (Array.isArray(result.openGraph.images)) result.openGraph.images = result.openGraph.images.map((img) => ({
|
|
2235
|
+
...img,
|
|
2236
|
+
url: resolveUrl(img.url, base)
|
|
2237
|
+
}));
|
|
2238
|
+
else if (result.openGraph.images) result.openGraph.images = {
|
|
2239
|
+
...result.openGraph.images,
|
|
2240
|
+
url: resolveUrl(result.openGraph.images.url, base)
|
|
2241
|
+
};
|
|
2242
|
+
if (result.openGraph.url && !isAbsoluteUrl(result.openGraph.url)) result.openGraph.url = resolveUrl(result.openGraph.url, base);
|
|
2243
|
+
}
|
|
2244
|
+
if (result.twitter) {
|
|
2245
|
+
result.twitter = { ...result.twitter };
|
|
2246
|
+
if (typeof result.twitter.images === "string") result.twitter.images = resolveUrl(result.twitter.images, base);
|
|
2247
|
+
else if (Array.isArray(result.twitter.images)) {
|
|
2248
|
+
const resolved = result.twitter.images.map((img) => typeof img === "string" ? resolveUrl(img, base) : {
|
|
2249
|
+
...img,
|
|
2250
|
+
url: resolveUrl(img.url, base)
|
|
2251
|
+
});
|
|
2252
|
+
const allStrings = resolved.every((r) => typeof r === "string");
|
|
2253
|
+
result.twitter.images = allStrings ? resolved : resolved;
|
|
2254
|
+
} else if (result.twitter.images) result.twitter.images = {
|
|
2255
|
+
...result.twitter.images,
|
|
2256
|
+
url: resolveUrl(result.twitter.images.url, base)
|
|
2257
|
+
};
|
|
2258
|
+
}
|
|
2259
|
+
if (result.alternates) {
|
|
2260
|
+
result.alternates = { ...result.alternates };
|
|
2261
|
+
if (result.alternates.canonical && !isAbsoluteUrl(result.alternates.canonical)) result.alternates.canonical = resolveUrl(result.alternates.canonical, base);
|
|
2262
|
+
if (result.alternates.languages) {
|
|
2263
|
+
const langs = {};
|
|
2264
|
+
for (const [lang, url] of Object.entries(result.alternates.languages)) langs[lang] = isAbsoluteUrl(url) ? url : resolveUrl(url, base);
|
|
2265
|
+
result.alternates.languages = langs;
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
if (result.icons) {
|
|
2269
|
+
result.icons = { ...result.icons };
|
|
2270
|
+
if (typeof result.icons.icon === "string") result.icons.icon = resolveUrl(result.icons.icon, base);
|
|
2271
|
+
else if (Array.isArray(result.icons.icon)) result.icons.icon = result.icons.icon.map((i) => ({
|
|
2272
|
+
...i,
|
|
2273
|
+
url: resolveUrl(i.url, base)
|
|
2274
|
+
}));
|
|
2275
|
+
if (typeof result.icons.apple === "string") result.icons.apple = resolveUrl(result.icons.apple, base);
|
|
2276
|
+
else if (Array.isArray(result.icons.apple)) result.icons.apple = result.icons.apple.map((i) => ({
|
|
2277
|
+
...i,
|
|
2278
|
+
url: resolveUrl(i.url, base)
|
|
2279
|
+
}));
|
|
2280
|
+
}
|
|
2281
|
+
return result;
|
|
2282
|
+
}
|
|
2283
|
+
//#endregion
|
|
2284
|
+
//#region src/server/form-data.ts
|
|
2285
|
+
/**
|
|
2286
|
+
* FormData preprocessing — schema-agnostic conversion of FormData to typed objects.
|
|
2287
|
+
*
|
|
2288
|
+
* FormData is all strings. Schema validation expects typed values. This module
|
|
2289
|
+
* bridges the gap with intelligent coercion that runs *before* schema validation.
|
|
2290
|
+
*
|
|
2291
|
+
* Inspired by zod-form-data, but schema-agnostic — works with any Standard Schema
|
|
2292
|
+
* library (Zod, Valibot, ArkType).
|
|
2293
|
+
*
|
|
2294
|
+
* See design/08-forms-and-actions.md §"parseFormData() and coerce helpers"
|
|
2295
|
+
*/
|
|
2296
|
+
/**
|
|
2297
|
+
* Convert FormData into a plain object with intelligent coercion.
|
|
2298
|
+
*
|
|
2299
|
+
* Handles:
|
|
2300
|
+
* - **Duplicate keys → arrays**: `tags=js&tags=ts` → `{ tags: ["js", "ts"] }`
|
|
2301
|
+
* - **Nested dot-paths**: `user.name=Alice` → `{ user: { name: "Alice" } }`
|
|
2302
|
+
* - **Empty strings → undefined**: Enables `.optional()` semantics in schemas
|
|
2303
|
+
* - **Empty Files → undefined**: File inputs with no selection become `undefined`
|
|
2304
|
+
* - **Strips `$ACTION_*` fields**: React's internal hidden fields are excluded
|
|
2305
|
+
*/
|
|
2306
|
+
function parseFormData(formData) {
|
|
2307
|
+
const flat = {};
|
|
2308
|
+
for (const key of new Set(formData.keys())) {
|
|
2309
|
+
if (key.startsWith("$ACTION_")) continue;
|
|
2310
|
+
const processed = formData.getAll(key).map(normalizeValue);
|
|
2311
|
+
if (processed.length === 1) flat[key] = processed[0];
|
|
2312
|
+
else flat[key] = processed.filter((v) => v !== void 0);
|
|
2313
|
+
}
|
|
2314
|
+
return expandDotPaths(flat);
|
|
2315
|
+
}
|
|
2316
|
+
/**
|
|
2317
|
+
* Normalize a single FormData entry value.
|
|
2318
|
+
* - Empty strings → undefined (enables .optional() semantics)
|
|
2319
|
+
* - Empty File objects (no selection) → undefined
|
|
2320
|
+
* - Everything else passes through as-is
|
|
2321
|
+
*/
|
|
2322
|
+
function normalizeValue(value) {
|
|
2323
|
+
if (typeof value === "string") return value === "" ? void 0 : value;
|
|
2324
|
+
if (value instanceof File && value.size === 0 && value.name === "") return;
|
|
2325
|
+
return value;
|
|
2326
|
+
}
|
|
2327
|
+
/**
|
|
2328
|
+
* Expand dot-notation keys into nested objects.
|
|
2329
|
+
* `{ "user.name": "Alice", "user.age": "30" }` → `{ user: { name: "Alice", age: "30" } }`
|
|
2330
|
+
*
|
|
2331
|
+
* Keys without dots are left as-is. Bracket notation (e.g. `items[0]`) is NOT
|
|
2332
|
+
* supported — use dot notation (`items.0`) instead.
|
|
2333
|
+
*/
|
|
2334
|
+
function expandDotPaths(flat) {
|
|
2335
|
+
const result = {};
|
|
2336
|
+
let hasDotPaths = false;
|
|
2337
|
+
for (const key of Object.keys(flat)) if (key.includes(".")) {
|
|
2338
|
+
hasDotPaths = true;
|
|
2339
|
+
break;
|
|
2340
|
+
}
|
|
2341
|
+
if (!hasDotPaths) return flat;
|
|
2342
|
+
for (const [key, value] of Object.entries(flat)) {
|
|
2343
|
+
if (!key.includes(".")) {
|
|
2344
|
+
result[key] = value;
|
|
2345
|
+
continue;
|
|
2346
|
+
}
|
|
2347
|
+
const parts = key.split(".");
|
|
2348
|
+
let current = result;
|
|
2349
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
2350
|
+
const part = parts[i];
|
|
2351
|
+
if (current[part] === void 0 || current[part] === null) current[part] = {};
|
|
2352
|
+
if (typeof current[part] !== "object" || current[part] instanceof File) current[part] = {};
|
|
2353
|
+
current = current[part];
|
|
2354
|
+
}
|
|
2355
|
+
current[parts[parts.length - 1]] = value;
|
|
2356
|
+
}
|
|
2357
|
+
return result;
|
|
2358
|
+
}
|
|
2359
|
+
/**
|
|
2360
|
+
* Schema-agnostic coercion primitives for common FormData patterns.
|
|
2361
|
+
*
|
|
2362
|
+
* These are plain transform functions — they compose with any schema library's
|
|
2363
|
+
* `transform`/`preprocess` pipeline:
|
|
2364
|
+
*
|
|
2365
|
+
* ```ts
|
|
2366
|
+
* // Zod
|
|
2367
|
+
* z.preprocess(coerce.number, z.number())
|
|
2368
|
+
* // Valibot
|
|
2369
|
+
* v.pipe(v.unknown(), v.transform(coerce.number), v.number())
|
|
2370
|
+
* ```
|
|
2371
|
+
*/
|
|
2372
|
+
var coerce = {
|
|
2373
|
+
number(value) {
|
|
2374
|
+
if (value === void 0 || value === null || value === "") return void 0;
|
|
2375
|
+
if (typeof value === "number") return value;
|
|
2376
|
+
if (typeof value !== "string") return void 0;
|
|
2377
|
+
const num = Number(value);
|
|
2378
|
+
if (Number.isNaN(num)) return void 0;
|
|
2379
|
+
return num;
|
|
2380
|
+
},
|
|
2381
|
+
checkbox(value) {
|
|
2382
|
+
if (value === void 0 || value === null || value === "") return false;
|
|
2383
|
+
if (typeof value === "boolean") return value;
|
|
2384
|
+
return typeof value === "string" && value.length > 0;
|
|
2385
|
+
},
|
|
2386
|
+
json(value) {
|
|
2387
|
+
if (value === void 0 || value === null || value === "") return void 0;
|
|
2388
|
+
if (typeof value !== "string") return value;
|
|
2389
|
+
try {
|
|
2390
|
+
return JSON.parse(value);
|
|
2391
|
+
} catch {
|
|
2392
|
+
return;
|
|
2393
|
+
}
|
|
2394
|
+
}
|
|
2395
|
+
};
|
|
2396
|
+
//#endregion
|
|
2397
|
+
//#region src/server/action-client.ts
|
|
2398
|
+
/**
|
|
2399
|
+
* createActionClient — typed middleware and schema validation for server actions.
|
|
2400
|
+
*
|
|
2401
|
+
* Inspired by next-safe-action. Provides a builder API:
|
|
2402
|
+
* createActionClient({ middleware }) → .schema(z.object(...)) → .action(fn)
|
|
2403
|
+
*
|
|
2404
|
+
* The resulting action function satisfies both:
|
|
2405
|
+
* 1. Direct call: action(input) → Promise<ActionResult>
|
|
2406
|
+
* 2. React useActionState: (prevState, formData) => Promise<ActionResult>
|
|
2407
|
+
*
|
|
2408
|
+
* See design/08-forms-and-actions.md §"Middleware for Server Actions"
|
|
2409
|
+
*/
|
|
2410
|
+
/**
|
|
2411
|
+
* Typed error class for server actions. Carries a string code and optional data.
|
|
2412
|
+
* When thrown from middleware or the action body, the action short-circuits and
|
|
2413
|
+
* the client receives `result.serverError`.
|
|
2414
|
+
*
|
|
2415
|
+
* In production, unexpected errors (non-ActionError) return `{ code: 'INTERNAL_ERROR' }`
|
|
2416
|
+
* with no message. In dev, `data.message` is included.
|
|
2417
|
+
*/
|
|
2418
|
+
var ActionError = class extends Error {
|
|
2419
|
+
code;
|
|
2420
|
+
data;
|
|
2421
|
+
constructor(code, data) {
|
|
2422
|
+
super(`ActionError: ${code}`);
|
|
2423
|
+
this.name = "ActionError";
|
|
2424
|
+
this.code = code;
|
|
2425
|
+
this.data = data;
|
|
2426
|
+
}
|
|
2427
|
+
};
|
|
2428
|
+
/** Check if a schema implements the Standard Schema protocol. */
|
|
2429
|
+
function isStandardSchema(schema) {
|
|
2430
|
+
return typeof schema === "object" && schema !== null && "~standard" in schema && typeof schema["~standard"].validate === "function";
|
|
2431
|
+
}
|
|
2432
|
+
/**
|
|
2433
|
+
* Run middleware array or single function. Returns merged context.
|
|
2434
|
+
*/
|
|
2435
|
+
async function runActionMiddleware(middleware) {
|
|
2436
|
+
if (!middleware) return {};
|
|
2437
|
+
if (Array.isArray(middleware)) {
|
|
2438
|
+
let merged = {};
|
|
2439
|
+
for (const mw of middleware) {
|
|
2440
|
+
const result = await mw();
|
|
2441
|
+
merged = {
|
|
2442
|
+
...merged,
|
|
2443
|
+
...result
|
|
2444
|
+
};
|
|
2445
|
+
}
|
|
2446
|
+
return merged;
|
|
2447
|
+
}
|
|
2448
|
+
return await middleware();
|
|
2449
|
+
}
|
|
2450
|
+
/**
|
|
2451
|
+
* Extract validation errors from a schema error.
|
|
2452
|
+
* Supports Zod's flatten() and generic issues array.
|
|
2453
|
+
*/
|
|
2454
|
+
function extractValidationErrors(error) {
|
|
2455
|
+
if (typeof error.flatten === "function") return error.flatten().fieldErrors;
|
|
2456
|
+
if (error.issues) {
|
|
2457
|
+
const errors = {};
|
|
2458
|
+
for (const issue of error.issues) {
|
|
2459
|
+
const path = issue.path?.join(".") ?? "_root";
|
|
2460
|
+
if (!errors[path]) errors[path] = [];
|
|
2461
|
+
errors[path].push(issue.message);
|
|
2462
|
+
}
|
|
2463
|
+
return errors;
|
|
2464
|
+
}
|
|
2465
|
+
return { _root: ["Validation failed"] };
|
|
2466
|
+
}
|
|
2467
|
+
/**
|
|
2468
|
+
* Extract validation errors from Standard Schema issues.
|
|
2469
|
+
*/
|
|
2470
|
+
function extractStandardSchemaErrors(issues) {
|
|
2471
|
+
const errors = {};
|
|
2472
|
+
for (const issue of issues) {
|
|
2473
|
+
const path = issue.path?.map((p) => {
|
|
2474
|
+
if (typeof p === "object" && p !== null && "key" in p) return String(p.key);
|
|
2475
|
+
return String(p);
|
|
2476
|
+
}).join(".") ?? "_root";
|
|
2477
|
+
if (!errors[path]) errors[path] = [];
|
|
2478
|
+
errors[path].push(issue.message);
|
|
2479
|
+
}
|
|
2480
|
+
return Object.keys(errors).length > 0 ? errors : { _root: ["Validation failed"] };
|
|
2481
|
+
}
|
|
2482
|
+
/**
|
|
2483
|
+
* Wrap unexpected errors into a safe server error result.
|
|
2484
|
+
* ActionError → typed result. Other errors → INTERNAL_ERROR (no leak).
|
|
2485
|
+
*
|
|
2486
|
+
* Exported for use by action-handler.ts to catch errors from raw 'use server'
|
|
2487
|
+
* functions that don't use createActionClient.
|
|
2488
|
+
*/
|
|
2489
|
+
function handleActionError(error) {
|
|
2490
|
+
if (error instanceof ActionError) return { serverError: {
|
|
2491
|
+
code: error.code,
|
|
2492
|
+
...error.data ? { data: error.data } : {}
|
|
2493
|
+
} };
|
|
2494
|
+
return { serverError: {
|
|
2495
|
+
code: "INTERNAL_ERROR",
|
|
2496
|
+
...typeof process !== "undefined" && process.env.NODE_ENV !== "production" && error instanceof Error ? { data: { message: error.message } } : {}
|
|
2497
|
+
} };
|
|
2498
|
+
}
|
|
2499
|
+
/**
|
|
2500
|
+
* Create a typed action client with middleware and schema validation.
|
|
2501
|
+
*
|
|
2502
|
+
* @example
|
|
2503
|
+
* ```ts
|
|
2504
|
+
* const action = createActionClient({
|
|
2505
|
+
* middleware: async () => {
|
|
2506
|
+
* const user = await getUser()
|
|
2507
|
+
* if (!user) throw new ActionError('UNAUTHORIZED')
|
|
2508
|
+
* return { user }
|
|
2509
|
+
* },
|
|
2510
|
+
* })
|
|
2511
|
+
*
|
|
2512
|
+
* export const createTodo = action
|
|
2513
|
+
* .schema(z.object({ title: z.string().min(1) }))
|
|
2514
|
+
* .action(async ({ input, ctx }) => {
|
|
2515
|
+
* await db.todos.create({ ...input, userId: ctx.user.id })
|
|
2516
|
+
* })
|
|
2517
|
+
* ```
|
|
2518
|
+
*/
|
|
2519
|
+
function createActionClient(config = {}) {
|
|
2520
|
+
function buildAction(schema, fn) {
|
|
2521
|
+
async function actionHandler(...args) {
|
|
2522
|
+
try {
|
|
2523
|
+
const ctx = await runActionMiddleware(config.middleware);
|
|
2524
|
+
let rawInput;
|
|
2525
|
+
if (args.length === 2 && args[1] instanceof FormData) rawInput = schema ? parseFormData(args[1]) : args[1];
|
|
2526
|
+
else rawInput = args[0];
|
|
2527
|
+
if (config.fileSizeLimit !== void 0 && rawInput && typeof rawInput === "object") {
|
|
2528
|
+
const fileSizeErrors = validateFileSizes(rawInput, config.fileSizeLimit);
|
|
2529
|
+
if (fileSizeErrors) return {
|
|
2530
|
+
validationErrors: fileSizeErrors,
|
|
2531
|
+
submittedValues: stripFiles(rawInput)
|
|
2532
|
+
};
|
|
2533
|
+
}
|
|
2534
|
+
const submittedValues = schema ? stripFiles(rawInput) : void 0;
|
|
2535
|
+
let input;
|
|
2536
|
+
if (schema) if (isStandardSchema(schema)) {
|
|
2537
|
+
const result = schema["~standard"].validate(rawInput);
|
|
2538
|
+
if (result instanceof Promise) throw new Error("[timber] createActionClient: schema returned a Promise — only sync schemas are supported.");
|
|
2539
|
+
if (result.issues) {
|
|
2540
|
+
const validationErrors = extractStandardSchemaErrors(result.issues);
|
|
2541
|
+
logValidationFailure(validationErrors);
|
|
2542
|
+
return {
|
|
2543
|
+
validationErrors,
|
|
2544
|
+
submittedValues
|
|
2545
|
+
};
|
|
2546
|
+
}
|
|
2547
|
+
input = result.value;
|
|
2548
|
+
} else if (typeof schema.safeParse === "function") {
|
|
2549
|
+
const result = schema.safeParse(rawInput);
|
|
2550
|
+
if (!result.success) {
|
|
2551
|
+
const validationErrors = extractValidationErrors(result.error);
|
|
2552
|
+
logValidationFailure(validationErrors);
|
|
2553
|
+
return {
|
|
2554
|
+
validationErrors,
|
|
2555
|
+
submittedValues
|
|
2556
|
+
};
|
|
2557
|
+
}
|
|
2558
|
+
input = result.data;
|
|
2559
|
+
} else try {
|
|
2560
|
+
input = schema.parse(rawInput);
|
|
2561
|
+
} catch (parseError) {
|
|
2562
|
+
const validationErrors = extractValidationErrors(parseError);
|
|
2563
|
+
logValidationFailure(validationErrors);
|
|
2564
|
+
return {
|
|
2565
|
+
validationErrors,
|
|
2566
|
+
submittedValues
|
|
2567
|
+
};
|
|
2568
|
+
}
|
|
2569
|
+
else input = rawInput;
|
|
2570
|
+
return { data: await fn({
|
|
2571
|
+
ctx,
|
|
2572
|
+
input
|
|
2573
|
+
}) };
|
|
2574
|
+
} catch (error) {
|
|
2575
|
+
return handleActionError(error);
|
|
2576
|
+
}
|
|
2577
|
+
}
|
|
2578
|
+
return actionHandler;
|
|
2579
|
+
}
|
|
2580
|
+
return {
|
|
2581
|
+
schema(schema) {
|
|
2582
|
+
return { action(fn) {
|
|
2583
|
+
return buildAction(schema, fn);
|
|
2584
|
+
} };
|
|
2585
|
+
},
|
|
2586
|
+
action(fn) {
|
|
2587
|
+
return buildAction(void 0, fn);
|
|
2588
|
+
}
|
|
2589
|
+
};
|
|
2590
|
+
}
|
|
2591
|
+
/**
|
|
2592
|
+
* Convenience wrapper for the common case: validate input, run handler.
|
|
2593
|
+
* No middleware needed.
|
|
2594
|
+
*
|
|
2595
|
+
* @example
|
|
2596
|
+
* ```ts
|
|
2597
|
+
* 'use server'
|
|
2598
|
+
* import { validated } from '@timber/app/server'
|
|
2599
|
+
* import { z } from 'zod'
|
|
2600
|
+
*
|
|
2601
|
+
* export const createTodo = validated(
|
|
2602
|
+
* z.object({ title: z.string().min(1) }),
|
|
2603
|
+
* async (input) => {
|
|
2604
|
+
* await db.todos.create(input)
|
|
2605
|
+
* }
|
|
2606
|
+
* )
|
|
2607
|
+
* ```
|
|
2608
|
+
*/
|
|
2609
|
+
function validated(schema, handler) {
|
|
2610
|
+
return createActionClient().schema(schema).action(async ({ input }) => handler(input));
|
|
2611
|
+
}
|
|
2612
|
+
/**
|
|
2613
|
+
* Log validation failures in dev mode so developers can see what went wrong.
|
|
2614
|
+
* In production, validation errors are only returned to the client.
|
|
2615
|
+
*/
|
|
2616
|
+
function logValidationFailure(errors) {
|
|
2617
|
+
if (!(typeof process !== "undefined" && process.env.NODE_ENV !== "production")) return;
|
|
2618
|
+
const fields = Object.entries(errors).map(([field, messages]) => ` ${field}: ${messages.join(", ")}`).join("\n");
|
|
2619
|
+
console.warn(`[timber] action schema validation failed:\n${fields}`);
|
|
2620
|
+
}
|
|
2621
|
+
/**
|
|
2622
|
+
* Validate that all File objects in the input are within the size limit.
|
|
2623
|
+
* Returns validation errors keyed by field name, or null if all files are ok.
|
|
2624
|
+
*/
|
|
2625
|
+
function validateFileSizes(input, limit) {
|
|
2626
|
+
const errors = {};
|
|
2627
|
+
const limitKb = Math.round(limit / 1024);
|
|
2628
|
+
const limitLabel = limit >= 1024 * 1024 ? `${Math.round(limit / (1024 * 1024))}MB` : `${limitKb}KB`;
|
|
2629
|
+
for (const [key, value] of Object.entries(input)) if (value instanceof File && value.size > limit) errors[key] = [`File "${value.name}" (${formatSize(value.size)}) exceeds the ${limitLabel} limit`];
|
|
2630
|
+
else if (Array.isArray(value)) {
|
|
2631
|
+
const oversized = value.filter((item) => item instanceof File && item.size > limit);
|
|
2632
|
+
if (oversized.length > 0) errors[key] = oversized.map((f) => `File "${f.name}" (${formatSize(f.size)}) exceeds the ${limitLabel} limit`);
|
|
2633
|
+
}
|
|
2634
|
+
return Object.keys(errors).length > 0 ? errors : null;
|
|
2635
|
+
}
|
|
2636
|
+
/**
|
|
2637
|
+
* Strip File objects from a value, returning a plain object safe for
|
|
2638
|
+
* serialization. File objects can't be serialized and shouldn't be echoed back.
|
|
2639
|
+
*/
|
|
2640
|
+
function stripFiles(value) {
|
|
2641
|
+
if (value === null || value === void 0) return void 0;
|
|
2642
|
+
if (typeof value !== "object") return void 0;
|
|
2643
|
+
const result = {};
|
|
2644
|
+
for (const [k, v] of Object.entries(value)) {
|
|
2645
|
+
if (v instanceof File) continue;
|
|
2646
|
+
if (Array.isArray(v)) result[k] = v.filter((item) => !(item instanceof File));
|
|
2647
|
+
else if (typeof v === "object" && v !== null && !(v instanceof File)) result[k] = stripFiles(v) ?? {};
|
|
2648
|
+
else result[k] = v;
|
|
2649
|
+
}
|
|
2650
|
+
return result;
|
|
2651
|
+
}
|
|
2652
|
+
//#endregion
|
|
2653
|
+
//#region src/server/form-flash.ts
|
|
2654
|
+
/**
|
|
2655
|
+
* Form Flash — ALS-based store for no-JS form action results.
|
|
2656
|
+
*
|
|
2657
|
+
* When a no-JS form action completes, the server re-renders the page with
|
|
2658
|
+
* the action result injected via AsyncLocalStorage instead of redirecting
|
|
2659
|
+
* (which would discard the result). Server components read the flash and
|
|
2660
|
+
* pass it to client form components as the initial `useActionState` value.
|
|
2661
|
+
*
|
|
2662
|
+
* This follows the Remix/Rails pattern — the form component becomes the
|
|
2663
|
+
* single source of truth for both with-JS (React state) and no-JS (flash).
|
|
2664
|
+
*
|
|
2665
|
+
* The flash data is server-side only — never serialized to cookies or headers.
|
|
2666
|
+
*
|
|
2667
|
+
* See design/08-forms-and-actions.md §"No-JS Error Round-Trip"
|
|
2668
|
+
*/
|
|
2669
|
+
var formFlashAls = new AsyncLocalStorage();
|
|
2670
|
+
/**
|
|
2671
|
+
* Read the form flash data for the current request.
|
|
2672
|
+
*
|
|
2673
|
+
* Returns `null` if no flash data is present (i.e., this is a normal page
|
|
2674
|
+
* render, not a re-render after a no-JS form submission).
|
|
2675
|
+
*
|
|
2676
|
+
* Pass the flash as the initial state to `useActionState` so the form
|
|
2677
|
+
* component has a single source of truth for both with-JS and no-JS paths:
|
|
2678
|
+
*
|
|
2679
|
+
* ```tsx
|
|
2680
|
+
* // app/contact/page.tsx (server component)
|
|
2681
|
+
* import { getFormFlash } from '@timber/app/server'
|
|
2682
|
+
*
|
|
2683
|
+
* export default function ContactPage() {
|
|
2684
|
+
* const flash = getFormFlash()
|
|
2685
|
+
* return <ContactForm flash={flash} />
|
|
2686
|
+
* }
|
|
2687
|
+
*
|
|
2688
|
+
* // app/contact/form.tsx (client component)
|
|
2689
|
+
* export function ContactForm({ flash }) {
|
|
2690
|
+
* const [result, action, isPending] = useActionState(submitContact, flash)
|
|
2691
|
+
* // result is the single source of truth — flash seeds it on no-JS
|
|
2692
|
+
* }
|
|
2693
|
+
* ```
|
|
2694
|
+
*/
|
|
2695
|
+
function getFormFlash() {
|
|
2696
|
+
return formFlashAls.getStore() ?? null;
|
|
2697
|
+
}
|
|
2698
|
+
//#endregion
|
|
2699
|
+
//#region src/server/actions.ts
|
|
2700
|
+
/**
|
|
2701
|
+
* Server action primitives: revalidatePath, revalidateTag, and the action handler.
|
|
2702
|
+
*
|
|
2703
|
+
* - revalidatePath(path) re-renders the route at that path and returns the RSC
|
|
2704
|
+
* flight payload for inline reconciliation.
|
|
2705
|
+
* - revalidateTag(tag) invalidates cached shells and 'use cache' entries by tag.
|
|
2706
|
+
*
|
|
2707
|
+
* Both are callable from anywhere on the server — actions, API routes, handlers.
|
|
2708
|
+
*
|
|
2709
|
+
* The action handler processes incoming action requests, validates CSRF,
|
|
2710
|
+
* enforces body limits, executes the action, and returns the response
|
|
2711
|
+
* (with piggybacked RSC payload if revalidatePath was called).
|
|
2712
|
+
*
|
|
2713
|
+
* See design/08-forms-and-actions.md
|
|
2714
|
+
*/
|
|
2715
|
+
var revalidationAls = new AsyncLocalStorage();
|
|
2716
|
+
/**
|
|
2717
|
+
* Get the current revalidation state. Throws if called outside an action context.
|
|
2718
|
+
* @internal
|
|
2719
|
+
*/
|
|
2720
|
+
function getRevalidationState() {
|
|
2721
|
+
const state = revalidationAls.getStore();
|
|
2722
|
+
if (!state) throw new Error("revalidatePath/revalidateTag called outside of a server action context. These functions can only be called during action execution.");
|
|
2723
|
+
return state;
|
|
2724
|
+
}
|
|
2725
|
+
/**
|
|
2726
|
+
* Re-render the route at `path` and include the RSC flight payload in the
|
|
2727
|
+
* action response. The client reconciles inline — no separate fetch needed.
|
|
2728
|
+
*
|
|
2729
|
+
* Can be called from server actions, API routes, or any server-side context.
|
|
2730
|
+
*
|
|
2731
|
+
* @param path - The path to re-render (e.g. '/dashboard', '/todos').
|
|
2732
|
+
*/
|
|
2733
|
+
function revalidatePath(path) {
|
|
2734
|
+
const state = getRevalidationState();
|
|
2735
|
+
if (!state.paths.includes(path)) state.paths.push(path);
|
|
2736
|
+
}
|
|
2737
|
+
/**
|
|
2738
|
+
* Invalidate all pre-rendered shells and 'use cache' entries tagged with `tag`.
|
|
2739
|
+
* Does not return a payload — the next request for an invalidated route re-renders fresh.
|
|
2740
|
+
*
|
|
2741
|
+
* @param tag - The cache tag to invalidate (e.g. 'products', 'user:123').
|
|
2742
|
+
*/
|
|
2743
|
+
function revalidateTag(tag) {
|
|
2744
|
+
const state = getRevalidationState();
|
|
2745
|
+
if (!state.tags.includes(tag)) state.tags.push(tag);
|
|
2746
|
+
}
|
|
2747
|
+
/**
|
|
2748
|
+
* Execute a server action and process revalidation.
|
|
2749
|
+
*
|
|
2750
|
+
* 1. Sets up revalidation state
|
|
2751
|
+
* 2. Calls the action function
|
|
2752
|
+
* 3. Processes revalidateTag calls (invalidates cache entries)
|
|
2753
|
+
* 4. Processes revalidatePath calls (re-renders and captures RSC payload)
|
|
2754
|
+
* 5. Returns the action result + optional RSC payload
|
|
2755
|
+
*
|
|
2756
|
+
* @param actionFn - The server action function to execute.
|
|
2757
|
+
* @param args - Arguments to pass to the action.
|
|
2758
|
+
* @param config - Handler configuration (cache handler, renderer).
|
|
2759
|
+
*/
|
|
2760
|
+
async function executeAction(actionFn, args, config = {}, spanMeta) {
|
|
2761
|
+
const state = {
|
|
2762
|
+
paths: [],
|
|
2763
|
+
tags: []
|
|
2764
|
+
};
|
|
2765
|
+
let actionResult;
|
|
2766
|
+
let redirectTo;
|
|
2767
|
+
let redirectStatus;
|
|
2768
|
+
await revalidationAls.run(state, async () => {
|
|
2769
|
+
try {
|
|
2770
|
+
actionResult = await withSpan("timber.action", {
|
|
2771
|
+
...spanMeta?.actionFile ? { "timber.action_file": spanMeta.actionFile } : {},
|
|
2772
|
+
...spanMeta?.actionName ? { "timber.action_name": spanMeta.actionName } : {}
|
|
2773
|
+
}, () => actionFn(...args));
|
|
2774
|
+
} catch (error) {
|
|
2775
|
+
if (error instanceof RedirectSignal) {
|
|
2776
|
+
redirectTo = error.location;
|
|
2777
|
+
redirectStatus = error.status;
|
|
2778
|
+
} else throw error;
|
|
2779
|
+
}
|
|
2780
|
+
});
|
|
2781
|
+
if (state.tags.length > 0 && config.cacheHandler) await Promise.all(state.tags.map((tag) => config.cacheHandler.invalidate({ tag })));
|
|
2782
|
+
let revalidation;
|
|
2783
|
+
if (state.paths.length > 0 && config.renderer) {
|
|
2784
|
+
const path = state.paths[0];
|
|
2785
|
+
try {
|
|
2786
|
+
revalidation = await config.renderer(path);
|
|
2787
|
+
} catch (renderError) {
|
|
2788
|
+
if (renderError instanceof RedirectSignal) {
|
|
2789
|
+
redirectTo = renderError.location;
|
|
2790
|
+
redirectStatus = renderError.status;
|
|
2791
|
+
} else console.error("[timber] revalidatePath render failed:", renderError);
|
|
2792
|
+
}
|
|
2793
|
+
}
|
|
2794
|
+
return {
|
|
2795
|
+
actionResult,
|
|
2796
|
+
revalidation,
|
|
2797
|
+
...redirectTo ? {
|
|
2798
|
+
redirectTo,
|
|
2799
|
+
redirectStatus
|
|
2800
|
+
} : {}
|
|
2801
|
+
};
|
|
2802
|
+
}
|
|
2803
|
+
/**
|
|
2804
|
+
* Build an HTTP Response for a no-JS form submission.
|
|
2805
|
+
* Standard POST → 302 redirect pattern.
|
|
2806
|
+
*
|
|
2807
|
+
* @param redirectPath - Where to redirect after the action executes.
|
|
2808
|
+
*/
|
|
2809
|
+
function buildNoJsResponse(redirectPath, status = 302) {
|
|
2810
|
+
return new Response(null, {
|
|
2811
|
+
status,
|
|
2812
|
+
headers: { Location: redirectPath }
|
|
2813
|
+
});
|
|
2814
|
+
}
|
|
2815
|
+
/**
|
|
2816
|
+
* Detect whether the incoming request is an RSC action request (with JS)
|
|
2817
|
+
* or a plain HTML form POST (no JS).
|
|
2818
|
+
*
|
|
2819
|
+
* RSC action requests use Accept: text/x-component or Content-Type: text/x-component.
|
|
2820
|
+
*/
|
|
2821
|
+
function isRscActionRequest(req) {
|
|
2822
|
+
const accept = req.headers.get("Accept") ?? "";
|
|
2823
|
+
const contentType = req.headers.get("Content-Type") ?? "";
|
|
2824
|
+
return accept.includes("text/x-component") || contentType.includes("text/x-component");
|
|
2825
|
+
}
|
|
2826
|
+
//#endregion
|
|
2827
|
+
//#region src/server/route-handler.ts
|
|
2828
|
+
/** All recognized HTTP method export names. */
|
|
2829
|
+
var HTTP_METHODS = [
|
|
2830
|
+
"GET",
|
|
2831
|
+
"POST",
|
|
2832
|
+
"PUT",
|
|
2833
|
+
"PATCH",
|
|
2834
|
+
"DELETE",
|
|
2835
|
+
"HEAD",
|
|
2836
|
+
"OPTIONS"
|
|
2837
|
+
];
|
|
2838
|
+
/**
|
|
2839
|
+
* Resolve the full list of allowed methods for a route module.
|
|
2840
|
+
*
|
|
2841
|
+
* Includes:
|
|
2842
|
+
* - All explicitly exported methods
|
|
2843
|
+
* - HEAD (implicit when GET is exported)
|
|
2844
|
+
* - OPTIONS (always implicit)
|
|
2845
|
+
*/
|
|
2846
|
+
function resolveAllowedMethods(mod) {
|
|
2847
|
+
const methods = [];
|
|
2848
|
+
for (const method of HTTP_METHODS) {
|
|
2849
|
+
if (method === "HEAD" || method === "OPTIONS") continue;
|
|
2850
|
+
if (mod[method]) methods.push(method);
|
|
2851
|
+
}
|
|
2852
|
+
if (mod.GET && !mod.HEAD) methods.push("HEAD");
|
|
2853
|
+
else if (mod.HEAD) methods.push("HEAD");
|
|
2854
|
+
if (!mod.OPTIONS) methods.push("OPTIONS");
|
|
2855
|
+
else methods.push("OPTIONS");
|
|
2856
|
+
return methods;
|
|
2857
|
+
}
|
|
2858
|
+
/**
|
|
2859
|
+
* Handle an incoming request against a route.ts module.
|
|
2860
|
+
*
|
|
2861
|
+
* Dispatches to the named method handler, auto-generates 405/OPTIONS,
|
|
2862
|
+
* and merges response headers from ctx.headers.
|
|
2863
|
+
*/
|
|
2864
|
+
async function handleRouteRequest(mod, ctx) {
|
|
2865
|
+
const method = ctx.req.method.toUpperCase();
|
|
2866
|
+
const allowHeader = resolveAllowedMethods(mod).join(", ");
|
|
2867
|
+
if (method === "OPTIONS") {
|
|
2868
|
+
if (mod.OPTIONS) return runHandler(mod.OPTIONS, ctx);
|
|
2869
|
+
return new Response(null, {
|
|
2870
|
+
status: 204,
|
|
2871
|
+
headers: { Allow: allowHeader }
|
|
2872
|
+
});
|
|
2873
|
+
}
|
|
2874
|
+
if (method === "HEAD") {
|
|
2875
|
+
if (mod.HEAD) return runHandler(mod.HEAD, ctx);
|
|
2876
|
+
if (mod.GET) {
|
|
2877
|
+
const res = await runHandler(mod.GET, ctx);
|
|
2878
|
+
return new Response(null, {
|
|
2879
|
+
status: res.status,
|
|
2880
|
+
headers: res.headers
|
|
2881
|
+
});
|
|
2882
|
+
}
|
|
2883
|
+
}
|
|
2884
|
+
const handler = mod[method];
|
|
2885
|
+
if (!handler) return new Response(null, {
|
|
2886
|
+
status: 405,
|
|
2887
|
+
headers: { Allow: allowHeader }
|
|
2888
|
+
});
|
|
2889
|
+
return runHandler(handler, ctx);
|
|
2890
|
+
}
|
|
2891
|
+
/**
|
|
2892
|
+
* Run a handler, merge ctx.headers into the response, and catch errors.
|
|
2893
|
+
*/
|
|
2894
|
+
async function runHandler(handler, ctx) {
|
|
2895
|
+
try {
|
|
2896
|
+
return mergeResponseHeaders(await handler(ctx), ctx.headers);
|
|
2897
|
+
} catch (error) {
|
|
2898
|
+
console.error("[timber] Uncaught error in route.ts handler:", error);
|
|
2899
|
+
return new Response(null, { status: 500 });
|
|
2900
|
+
}
|
|
2901
|
+
}
|
|
2902
|
+
/**
|
|
2903
|
+
* Merge response headers from ctx.headers into the handler's response.
|
|
2904
|
+
* ctx.headers (set by middleware or the handler) are applied to the final response.
|
|
2905
|
+
* Handler-set headers take precedence over ctx.headers.
|
|
2906
|
+
*/
|
|
2907
|
+
function mergeResponseHeaders(res, ctxHeaders) {
|
|
2908
|
+
let hasCtxHeaders = false;
|
|
2909
|
+
ctxHeaders.forEach(() => {
|
|
2910
|
+
hasCtxHeaders = true;
|
|
2911
|
+
});
|
|
2912
|
+
if (!hasCtxHeaders) return res;
|
|
2913
|
+
const merged = new Headers();
|
|
2914
|
+
ctxHeaders.forEach((value, key) => merged.set(key, value));
|
|
2915
|
+
res.headers.forEach((value, key) => merged.set(key, value));
|
|
2916
|
+
return new Response(res.body, {
|
|
2917
|
+
status: res.status,
|
|
2918
|
+
statusText: res.statusText,
|
|
2919
|
+
headers: merged
|
|
2920
|
+
});
|
|
2921
|
+
}
|
|
2922
|
+
//#endregion
|
|
2923
|
+
export { AccessGate, ActionError, DEFAULT_LIMITS, DenySignal, METADATA_ROUTE_CONVENTIONS, RedirectSignal, RedirectType, RenderError, SlotAccessGate, WarningId, addSpanEvent, buildElementTree, buildNoJsResponse, callOnRequestError, canonicalize, classifyMetadataRoute, coerce, collectEarlyHintHeaders, cookies, createActionClient, createPipeline, deny, enforceBodyLimits, executeAction, flushResponse, formatLinkHeader, generateTraceId, getFormFlash, getLogger, getMetadataRouteAutoLink, getMetadataRouteServePath, getSetCookieHeaders, handleRouteRequest, hasOnRequestError, headers, isRscActionRequest, loadInstrumentation, logCacheMiss, logMiddlewareError, logMiddlewareShortCircuit, logProxyError, logRenderError, logRequestCompleted, logRequestReceived, logSlowRequest, logSwrRefetchFailed, logWaitUntilRejected, logWaitUntilUnsupported, markResponseFlushed, notFound, parseBodySize, parseFormData, permanentRedirect, redirect, redirectExternal, renderMetadataToElements, replaceTraceId, resolveAllowedMethods, resolveMetadata, resolveMetadataUrls, resolveSlotDenied, resolveStatusFile, resolveTitle, revalidatePath, revalidateTag, runMiddleware, runProxy, runWithEarlyHintsSender, runWithRequestContext, runWithTraceId, searchParams, sendEarlyHints103, setCookieSecrets, setLogger, setMutableCookieContext, setParsedSearchParams, setViteServer, spanId, traceId, validateCsrf, validated, waitUntil, warnCacheRequestProps, warnDenyAfterFlush, warnDenyInSuspense, warnDynamicApiInStaticBuild, warnRedirectInAccess, warnRedirectInSlotAccess, warnRedirectInSuspense, warnSlowSlotWithoutSuspense, warnStaticRequestApi, warnSuspenseWrappingChildren, withSpan };
|
|
2924
|
+
|
|
2925
|
+
//# sourceMappingURL=index.js.map
|