@timber-js/app 0.2.0-alpha.34 → 0.2.0-alpha.35
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/dist/_chunks/{als-registry-B7DbZ2hS.js → als-registry-Ba7URUIn.js} +1 -1
- package/dist/_chunks/als-registry-Ba7URUIn.js.map +1 -0
- package/dist/_chunks/chunk-DYhsFzuS.js +33 -0
- package/dist/_chunks/{debug-B3Gypr3D.js → debug-ECi_61pb.js} +1 -1
- package/dist/_chunks/{debug-B3Gypr3D.js.map → debug-ECi_61pb.js.map} +1 -1
- package/dist/_chunks/define-cookie-w5GWm_bL.js +93 -0
- package/dist/_chunks/define-cookie-w5GWm_bL.js.map +1 -0
- package/dist/_chunks/error-boundary-TYEQJZ1-.js +211 -0
- package/dist/_chunks/error-boundary-TYEQJZ1-.js.map +1 -0
- package/dist/_chunks/{format-RyoGQL74.js → format-cX7wzEp2.js} +2 -2
- package/dist/_chunks/{format-RyoGQL74.js.map → format-cX7wzEp2.js.map} +1 -1
- package/dist/_chunks/{interception-BOoWmLUA.js → interception-D2djYaIm.js} +112 -77
- package/dist/_chunks/interception-D2djYaIm.js.map +1 -0
- package/dist/_chunks/{metadata-routes-Cjmvi3rQ.js → metadata-routes-BU684ls2.js} +1 -1
- package/dist/_chunks/{metadata-routes-Cjmvi3rQ.js.map → metadata-routes-BU684ls2.js.map} +1 -1
- package/dist/_chunks/{request-context-BQUC8PHn.js → request-context-CZz_T0Bc.js} +40 -71
- package/dist/_chunks/request-context-CZz_T0Bc.js.map +1 -0
- package/dist/_chunks/segment-context-Dpq2XOKg.js +34 -0
- package/dist/_chunks/segment-context-Dpq2XOKg.js.map +1 -0
- package/dist/_chunks/stale-reload-C0ValzG7.js +47 -0
- package/dist/_chunks/stale-reload-C0ValzG7.js.map +1 -0
- package/dist/_chunks/{tracing-CemImE6h.js → tracing-BPyIzIdu.js} +2 -2
- package/dist/_chunks/{tracing-CemImE6h.js.map → tracing-BPyIzIdu.js.map} +1 -1
- package/dist/_chunks/{use-query-states-D5KaffOK.js → use-query-states-BvW0TKDn.js} +1 -1
- package/dist/_chunks/{use-query-states-D5KaffOK.js.map → use-query-states-BvW0TKDn.js.map} +1 -1
- package/dist/_chunks/wrappers-C1SN725w.js +331 -0
- package/dist/_chunks/wrappers-C1SN725w.js.map +1 -0
- package/dist/cache/index.js +1 -1
- package/dist/client/error-boundary.d.ts +10 -1
- package/dist/client/error-boundary.d.ts.map +1 -1
- package/dist/client/error-boundary.js +1 -125
- package/dist/client/index.d.ts +2 -2
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +193 -90
- package/dist/client/index.js.map +1 -1
- package/dist/client/link.d.ts +8 -8
- package/dist/client/link.d.ts.map +1 -1
- package/dist/client/navigation-context.d.ts +2 -2
- package/dist/client/router.d.ts +25 -3
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/rsc-fetch.d.ts +23 -2
- package/dist/client/rsc-fetch.d.ts.map +1 -1
- package/dist/client/segment-cache.d.ts +1 -1
- package/dist/client/segment-cache.d.ts.map +1 -1
- package/dist/client/stale-reload.d.ts +15 -0
- package/dist/client/stale-reload.d.ts.map +1 -1
- package/dist/client/top-loader.d.ts +1 -1
- package/dist/client/top-loader.d.ts.map +1 -1
- package/dist/client/use-params.d.ts +2 -2
- package/dist/client/use-params.d.ts.map +1 -1
- package/dist/client/use-query-states.d.ts +1 -1
- package/dist/codec.d.ts +21 -0
- package/dist/codec.d.ts.map +1 -0
- package/dist/cookies/define-cookie.d.ts +33 -12
- package/dist/cookies/define-cookie.d.ts.map +1 -1
- package/dist/cookies/index.js +1 -81
- package/dist/index.d.ts +87 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +346 -210
- package/dist/index.js.map +1 -1
- package/dist/params/define.d.ts +76 -0
- package/dist/params/define.d.ts.map +1 -0
- package/dist/params/index.d.ts +8 -0
- package/dist/params/index.d.ts.map +1 -0
- package/dist/params/index.js +104 -0
- package/dist/params/index.js.map +1 -0
- package/dist/plugins/adapter-build.d.ts.map +1 -1
- package/dist/plugins/build-manifest.d.ts.map +1 -1
- package/dist/plugins/client-chunks.d.ts +32 -0
- package/dist/plugins/client-chunks.d.ts.map +1 -0
- package/dist/plugins/entries.d.ts.map +1 -1
- package/dist/plugins/routing.d.ts.map +1 -1
- package/dist/plugins/server-bundle.d.ts.map +1 -1
- package/dist/plugins/static-build.d.ts.map +1 -1
- package/dist/routing/codegen.d.ts +2 -2
- package/dist/routing/codegen.d.ts.map +1 -1
- package/dist/routing/index.js +1 -1
- package/dist/routing/scanner.d.ts.map +1 -1
- package/dist/routing/status-file-lint.d.ts +2 -1
- package/dist/routing/status-file-lint.d.ts.map +1 -1
- package/dist/routing/types.d.ts +6 -4
- package/dist/routing/types.d.ts.map +1 -1
- package/dist/rsc-runtime/rsc.d.ts +1 -1
- package/dist/rsc-runtime/rsc.d.ts.map +1 -1
- package/dist/search-params/codecs.d.ts +1 -1
- package/dist/search-params/define.d.ts +153 -0
- package/dist/search-params/define.d.ts.map +1 -0
- package/dist/search-params/index.d.ts +4 -5
- package/dist/search-params/index.d.ts.map +1 -1
- package/dist/search-params/index.js +3 -474
- package/dist/search-params/registry.d.ts +1 -1
- package/dist/search-params/wrappers.d.ts +53 -0
- package/dist/search-params/wrappers.d.ts.map +1 -0
- package/dist/server/access-gate.d.ts +4 -0
- package/dist/server/access-gate.d.ts.map +1 -1
- package/dist/server/action-encryption.d.ts +76 -0
- package/dist/server/action-encryption.d.ts.map +1 -0
- package/dist/server/action-handler.d.ts.map +1 -1
- package/dist/server/als-registry.d.ts +4 -4
- package/dist/server/als-registry.d.ts.map +1 -1
- package/dist/server/build-manifest.d.ts +2 -2
- package/dist/server/early-hints.d.ts +13 -5
- package/dist/server/early-hints.d.ts.map +1 -1
- package/dist/server/error-boundary-wrapper.d.ts +4 -0
- package/dist/server/error-boundary-wrapper.d.ts.map +1 -1
- package/dist/server/flight-injection-state.d.ts +78 -0
- package/dist/server/flight-injection-state.d.ts.map +1 -0
- package/dist/server/form-data.d.ts +29 -0
- package/dist/server/form-data.d.ts.map +1 -1
- package/dist/server/html-injectors.d.ts.map +1 -1
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +1819 -1629
- package/dist/server/index.js.map +1 -1
- package/dist/server/node-stream-transforms.d.ts.map +1 -1
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/request-context.d.ts +28 -40
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/route-element-builder.d.ts +7 -0
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/route-matcher.d.ts +2 -2
- package/dist/server/route-matcher.d.ts.map +1 -1
- package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/slot-resolver.d.ts.map +1 -1
- package/dist/server/ssr-entry.d.ts.map +1 -1
- package/dist/server/ssr-render.d.ts +3 -0
- package/dist/server/ssr-render.d.ts.map +1 -1
- package/dist/server/tree-builder.d.ts +12 -8
- package/dist/server/tree-builder.d.ts.map +1 -1
- package/dist/server/types.d.ts +1 -3
- package/dist/server/types.d.ts.map +1 -1
- package/dist/server/version-skew.d.ts +61 -0
- package/dist/server/version-skew.d.ts.map +1 -0
- package/dist/shims/navigation-client.d.ts +1 -1
- package/dist/shims/navigation-client.d.ts.map +1 -1
- package/dist/shims/navigation.d.ts +1 -1
- package/dist/shims/navigation.d.ts.map +1 -1
- package/dist/utils/state-machine.d.ts +80 -0
- package/dist/utils/state-machine.d.ts.map +1 -0
- package/package.json +12 -8
- package/src/client/browser-entry.ts +55 -13
- package/src/client/error-boundary.tsx +18 -1
- package/src/client/index.ts +9 -1
- package/src/client/link.tsx +9 -9
- package/src/client/navigation-context.ts +2 -2
- package/src/client/router.ts +102 -55
- package/src/client/rsc-fetch.ts +63 -2
- package/src/client/segment-cache.ts +1 -1
- package/src/client/stale-reload.ts +28 -0
- package/src/client/top-loader.tsx +2 -2
- package/src/client/use-params.ts +3 -3
- package/src/client/use-query-states.ts +1 -1
- package/src/codec.ts +21 -0
- package/src/cookies/define-cookie.ts +69 -18
- package/src/index.ts +255 -65
- package/src/params/define.ts +260 -0
- package/src/params/index.ts +28 -0
- package/src/plugins/adapter-build.ts +6 -0
- package/src/plugins/build-manifest.ts +11 -0
- package/src/plugins/client-chunks.ts +65 -0
- package/src/plugins/entries.ts +3 -6
- package/src/plugins/routing.ts +40 -14
- package/src/plugins/server-bundle.ts +32 -1
- package/src/plugins/shims.ts +1 -1
- package/src/plugins/static-build.ts +8 -4
- package/src/routing/codegen.ts +109 -88
- package/src/routing/scanner.ts +55 -6
- package/src/routing/status-file-lint.ts +2 -1
- package/src/routing/types.ts +7 -4
- package/src/rsc-runtime/rsc.ts +2 -0
- package/src/search-params/codecs.ts +1 -1
- package/src/search-params/define.ts +504 -0
- package/src/search-params/index.ts +12 -18
- package/src/search-params/registry.ts +1 -1
- package/src/search-params/wrappers.ts +85 -0
- package/src/server/access-gate.tsx +38 -8
- package/src/server/action-encryption.ts +144 -0
- package/src/server/action-handler.ts +16 -0
- package/src/server/als-registry.ts +4 -4
- package/src/server/build-manifest.ts +4 -4
- package/src/server/early-hints.ts +36 -15
- package/src/server/error-boundary-wrapper.ts +57 -14
- package/src/server/flight-injection-state.ts +152 -0
- package/src/server/form-data.ts +76 -0
- package/src/server/html-injectors.ts +42 -26
- package/src/server/index.ts +2 -4
- package/src/server/node-stream-transforms.ts +68 -41
- package/src/server/pipeline.ts +98 -26
- package/src/server/request-context.ts +49 -124
- package/src/server/route-element-builder.ts +102 -99
- package/src/server/route-matcher.ts +2 -2
- package/src/server/rsc-entry/error-renderer.ts +3 -2
- package/src/server/rsc-entry/index.ts +26 -11
- package/src/server/rsc-entry/rsc-payload.ts +2 -2
- package/src/server/rsc-entry/ssr-renderer.ts +4 -4
- package/src/server/slot-resolver.ts +204 -206
- package/src/server/ssr-entry.ts +3 -1
- package/src/server/ssr-render.ts +3 -0
- package/src/server/tree-builder.ts +84 -48
- package/src/server/types.ts +1 -3
- package/src/server/version-skew.ts +104 -0
- package/src/shims/navigation-client.ts +1 -1
- package/src/shims/navigation.ts +1 -1
- package/src/utils/state-machine.ts +111 -0
- package/dist/_chunks/als-registry-B7DbZ2hS.js.map +0 -1
- package/dist/_chunks/interception-BOoWmLUA.js.map +0 -1
- package/dist/_chunks/request-context-BQUC8PHn.js.map +0 -1
- package/dist/_chunks/ssr-data-MjmprTmO.js +0 -88
- package/dist/_chunks/ssr-data-MjmprTmO.js.map +0 -1
- package/dist/_chunks/use-cookie-DX-l1_5E.js +0 -91
- package/dist/_chunks/use-cookie-DX-l1_5E.js.map +0 -1
- package/dist/client/error-boundary.js.map +0 -1
- package/dist/cookies/index.js.map +0 -1
- package/dist/plugins/dynamic-transform.d.ts +0 -72
- package/dist/plugins/dynamic-transform.d.ts.map +0 -1
- package/dist/search-params/analyze.d.ts +0 -54
- package/dist/search-params/analyze.d.ts.map +0 -1
- package/dist/search-params/builtin-codecs.d.ts +0 -105
- package/dist/search-params/builtin-codecs.d.ts.map +0 -1
- package/dist/search-params/create.d.ts +0 -106
- package/dist/search-params/create.d.ts.map +0 -1
- package/dist/search-params/index.js.map +0 -1
- package/dist/server/prerender.d.ts +0 -77
- package/dist/server/prerender.d.ts.map +0 -1
- package/src/plugins/dynamic-transform.ts +0 -161
- package/src/search-params/analyze.ts +0 -192
- package/src/search-params/builtin-codecs.ts +0 -228
- package/src/search-params/create.ts +0 -321
- package/src/server/prerender.ts +0 -139
|
@@ -35,7 +35,7 @@ import { isDebug } from './debug.js';
|
|
|
35
35
|
* gets the same data by calling the same cached functions (React.cache dedup).
|
|
36
36
|
*/
|
|
37
37
|
export function AccessGate(props: AccessGateProps): ReactElement | Promise<ReactElement> {
|
|
38
|
-
const { accessFn, params,
|
|
38
|
+
const { accessFn, params, segmentName, verdict, children } = props;
|
|
39
39
|
|
|
40
40
|
// Fast path: replay pre-computed verdict from the pre-render pass.
|
|
41
41
|
// This is synchronous — Suspense boundaries cannot interfere with the
|
|
@@ -52,7 +52,7 @@ export function AccessGate(props: AccessGateProps): ReactElement | Promise<React
|
|
|
52
52
|
|
|
53
53
|
// Fallback: call accessFn directly (used by tree-builder.ts which
|
|
54
54
|
// doesn't run a pre-render pass, and for backward compat).
|
|
55
|
-
return accessGateFallback(accessFn, params,
|
|
55
|
+
return accessGateFallback(accessFn, params, segmentName, children);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
/**
|
|
@@ -62,13 +62,12 @@ export function AccessGate(props: AccessGateProps): ReactElement | Promise<React
|
|
|
62
62
|
async function accessGateFallback(
|
|
63
63
|
accessFn: AccessGateProps['accessFn'],
|
|
64
64
|
params: AccessGateProps['params'],
|
|
65
|
-
searchParams: AccessGateProps['searchParams'],
|
|
66
65
|
segmentName: AccessGateProps['segmentName'],
|
|
67
66
|
children: ReactElement
|
|
68
67
|
): Promise<ReactElement> {
|
|
69
68
|
await withSpan('timber.access', { 'timber.segment': segmentName ?? 'unknown' }, async () => {
|
|
70
69
|
try {
|
|
71
|
-
await accessFn({ params
|
|
70
|
+
await accessFn({ params });
|
|
72
71
|
await setSpanAttribute('timber.result', 'pass');
|
|
73
72
|
} catch (error: unknown) {
|
|
74
73
|
if (error instanceof DenySignal) {
|
|
@@ -96,18 +95,28 @@ async function accessGateFallback(
|
|
|
96
95
|
* The HTTP status code is unaffected — slot denial is a UI concern, not
|
|
97
96
|
* a protocol concern. The parent layout and sibling slots still render.
|
|
98
97
|
*
|
|
98
|
+
* DeniedComponent is passed instead of a pre-built element so that
|
|
99
|
+
* DenySignal.data can be forwarded as the dangerouslyPassData prop
|
|
100
|
+
* and the slot name can be passed as the slot prop. See TIM-488.
|
|
101
|
+
*
|
|
99
102
|
* redirect() in slot access.ts is a dev-mode error — redirecting from a
|
|
100
103
|
* slot doesn't make architectural sense.
|
|
101
104
|
*/
|
|
102
105
|
export async function SlotAccessGate(props: SlotAccessGateProps): Promise<ReactElement> {
|
|
103
|
-
const { accessFn, params,
|
|
106
|
+
const { accessFn, params, DeniedComponent, slotName, createElement, defaultFallback, children } =
|
|
107
|
+
props;
|
|
104
108
|
|
|
105
109
|
try {
|
|
106
|
-
await accessFn({ params
|
|
110
|
+
await accessFn({ params });
|
|
107
111
|
} catch (error: unknown) {
|
|
108
112
|
// DenySignal → graceful degradation (denied.tsx → default.tsx → null)
|
|
113
|
+
// Build the denied element dynamically so DenySignal.data is forwarded.
|
|
109
114
|
if (error instanceof DenySignal) {
|
|
110
|
-
return
|
|
115
|
+
return (
|
|
116
|
+
buildDeniedFallback(DeniedComponent, slotName, error.data, createElement) ??
|
|
117
|
+
defaultFallback ??
|
|
118
|
+
null
|
|
119
|
+
);
|
|
111
120
|
}
|
|
112
121
|
|
|
113
122
|
// RedirectSignal in slot access → dev-mode error.
|
|
@@ -123,7 +132,11 @@ export async function SlotAccessGate(props: SlotAccessGateProps): Promise<ReactE
|
|
|
123
132
|
);
|
|
124
133
|
}
|
|
125
134
|
// In production, treat as a deny — render fallback rather than crash.
|
|
126
|
-
return
|
|
135
|
+
return (
|
|
136
|
+
buildDeniedFallback(DeniedComponent, slotName, undefined, createElement) ??
|
|
137
|
+
defaultFallback ??
|
|
138
|
+
null
|
|
139
|
+
);
|
|
127
140
|
}
|
|
128
141
|
|
|
129
142
|
// Unhandled error — re-throw so error boundaries can catch it.
|
|
@@ -141,3 +154,20 @@ export async function SlotAccessGate(props: SlotAccessGateProps): Promise<ReactE
|
|
|
141
154
|
// Access passed — render slot content.
|
|
142
155
|
return children;
|
|
143
156
|
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Build the denied fallback element dynamically with DenySignal data.
|
|
160
|
+
* Returns null if no DeniedComponent is available.
|
|
161
|
+
*/
|
|
162
|
+
function buildDeniedFallback(
|
|
163
|
+
DeniedComponent: SlotAccessGateProps['DeniedComponent'],
|
|
164
|
+
slotName: string,
|
|
165
|
+
data: unknown,
|
|
166
|
+
createElement: SlotAccessGateProps['createElement']
|
|
167
|
+
): ReactElement | null {
|
|
168
|
+
if (!DeniedComponent) return null;
|
|
169
|
+
return createElement(DeniedComponent, {
|
|
170
|
+
slot: slotName,
|
|
171
|
+
dangerouslyPassData: data,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server action bound args encryption utilities.
|
|
3
|
+
*
|
|
4
|
+
* Provides key management for the RSC plugin's built-in bound args encryption.
|
|
5
|
+
* The RSC plugin (@vitejs/plugin-rsc) handles the actual encrypt/decrypt via
|
|
6
|
+
* AES-256-GCM — this module handles:
|
|
7
|
+
*
|
|
8
|
+
* 1. Key sourcing: auto-generated at build time (embedded in bundle), overridable
|
|
9
|
+
* via env var for cross-build key sharing (rolling/blue-green deployments)
|
|
10
|
+
* 2. Build-time key expression generation for the RSC plugin's `defineEncryptionKey`
|
|
11
|
+
*
|
|
12
|
+
* Encryption is always on in production. In dev mode, it's on by default
|
|
13
|
+
* (matching the RSC plugin's behavior) but can be disabled for debugging.
|
|
14
|
+
*
|
|
15
|
+
* ## Known Security Considerations
|
|
16
|
+
*
|
|
17
|
+
* 1. **defineEncryptionKey is a raw JS expression.** The RSC plugin inlines it
|
|
18
|
+
* verbatim into generated code. We only emit the hardcoded string
|
|
19
|
+
* `process.env.TIMBER_ACTIONS_ENCRYPTION_KEY` — never user-controlled input.
|
|
20
|
+
* If this function is ever extended to accept configurable env var names,
|
|
21
|
+
* the expression MUST be validated against a safe pattern.
|
|
22
|
+
*
|
|
23
|
+
* 2. **Key material lives in GC-visible JS strings.** `atob()` decodes the key
|
|
24
|
+
* into a regular JavaScript string on the V8 heap. JavaScript has no
|
|
25
|
+
* `SecureString` or memory-zeroing primitive — this is an inherent platform
|
|
26
|
+
* limitation. Acceptable for web server use; would need review for FIPS.
|
|
27
|
+
*
|
|
28
|
+
* 3. **TIMBER_ACTIONS_ENCRYPTION_KEY must be set at both build time and runtime.**
|
|
29
|
+
* At build time, we validate the key format and emit a runtime expression.
|
|
30
|
+
* If the env var is present at build time but missing at runtime, the server
|
|
31
|
+
* will crash on first action invocation with an opaque `atob(undefined)` error.
|
|
32
|
+
* If the env var is present at runtime but was absent at build time, the RSC
|
|
33
|
+
* plugin will have generated its own key and the env var is silently ignored.
|
|
34
|
+
*
|
|
35
|
+
* See design/08-forms-and-actions.md §"Security"
|
|
36
|
+
* See design/13-security.md
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
// ─── Types ────────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
/** User-facing configuration for action bound args encryption. */
|
|
42
|
+
export interface ActionEncryptionConfig {
|
|
43
|
+
/**
|
|
44
|
+
* Disable encryption in dev mode for easier debugging.
|
|
45
|
+
* Has no effect in production — encryption is always enabled.
|
|
46
|
+
* Default: false (encryption is on in dev too).
|
|
47
|
+
*/
|
|
48
|
+
disableInDev?: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ─── Key Resolution ───────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Regex for safe `defineEncryptionKey` expressions.
|
|
55
|
+
*
|
|
56
|
+
* The RSC plugin inlines this expression verbatim into generated JavaScript.
|
|
57
|
+
* We restrict it to `process.env.<UPPER_SNAKE_CASE>` to prevent code injection.
|
|
58
|
+
* See "Known Security Considerations" at the top of this file.
|
|
59
|
+
*/
|
|
60
|
+
const SAFE_KEY_EXPR = /^process\.env\.[A-Z_][A-Z0-9_]*$/;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Build the `defineEncryptionKey` expression for the RSC plugin.
|
|
64
|
+
*
|
|
65
|
+
* The RSC plugin accepts a JavaScript expression string that will be
|
|
66
|
+
* inlined into the encryption runtime module. At runtime, this expression
|
|
67
|
+
* must evaluate to the base64-encoded encryption key.
|
|
68
|
+
*
|
|
69
|
+
* Priority:
|
|
70
|
+
* 1. `TIMBER_ACTIONS_ENCRYPTION_KEY` env var (for cross-build key sharing
|
|
71
|
+
* in rolling/blue-green deployments)
|
|
72
|
+
* 2. Auto-generated at build time (RSC plugin default — embedded in bundle,
|
|
73
|
+
* consistent across all instances of the same build)
|
|
74
|
+
*
|
|
75
|
+
* For env var keys, we generate a runtime expression that reads the env var.
|
|
76
|
+
* For auto-generated keys, we return undefined and let the RSC plugin handle it.
|
|
77
|
+
*/
|
|
78
|
+
export function resolveEncryptionKeyExpression(): string | undefined {
|
|
79
|
+
// Check for env var override — used for cross-build key sharing where
|
|
80
|
+
// multiple builds must agree on the same encryption key.
|
|
81
|
+
const envKey = process.env.TIMBER_ACTIONS_ENCRYPTION_KEY;
|
|
82
|
+
if (envKey) {
|
|
83
|
+
// Validate the key format (must be base64-encoded 32-byte key)
|
|
84
|
+
validateKeyFormat(envKey);
|
|
85
|
+
|
|
86
|
+
// Return a runtime expression that reads the env var at startup.
|
|
87
|
+
// This ensures the key is read at runtime, not embedded in the build.
|
|
88
|
+
const expr = 'process.env.TIMBER_ACTIONS_ENCRYPTION_KEY';
|
|
89
|
+
|
|
90
|
+
// Defense-in-depth: validate the expression matches our safe pattern.
|
|
91
|
+
// This is redundant today (hardcoded string), but protects against
|
|
92
|
+
// future refactors that might make the expression configurable.
|
|
93
|
+
if (!SAFE_KEY_EXPR.test(expr)) {
|
|
94
|
+
throw new Error(`Unsafe encryption key expression: ${expr}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return expr;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// No override — let the RSC plugin auto-generate a per-build key
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Determine whether action encryption should be enabled.
|
|
106
|
+
*
|
|
107
|
+
* Encryption is always enabled in production. In dev mode, it's enabled
|
|
108
|
+
* by default but can be disabled via config for debugging.
|
|
109
|
+
*/
|
|
110
|
+
export function shouldEnableEncryption(isDev: boolean, config?: ActionEncryptionConfig): boolean {
|
|
111
|
+
if (!isDev) return true; // Always on in production
|
|
112
|
+
if (config?.disableInDev) return false; // Opt-out in dev
|
|
113
|
+
return true; // On by default in dev too
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ─── Key Validation ───────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Validate that a key string is a valid base64-encoded 256-bit key.
|
|
120
|
+
* Throws a descriptive error if the key is malformed.
|
|
121
|
+
*/
|
|
122
|
+
export function validateKeyFormat(key: string): void {
|
|
123
|
+
// Decode base64 and check length (32 bytes = 256 bits)
|
|
124
|
+
try {
|
|
125
|
+
const decoded = atob(key);
|
|
126
|
+
const bytes = decoded.length;
|
|
127
|
+
if (bytes !== 32) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`TIMBER_ACTIONS_ENCRYPTION_KEY must be a base64-encoded 256-bit (32-byte) key. ` +
|
|
130
|
+
`Got ${bytes} bytes. Generate one with: ` +
|
|
131
|
+
`node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
} catch (error) {
|
|
135
|
+
if (error instanceof Error && error.message.includes('TIMBER_ACTIONS_ENCRYPTION_KEY')) {
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
throw new Error(
|
|
139
|
+
`TIMBER_ACTIONS_ENCRYPTION_KEY is not valid base64. ` +
|
|
140
|
+
`Generate a key with: ` +
|
|
141
|
+
`node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"`
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -31,6 +31,7 @@ import { handleActionError } from './action-client.js';
|
|
|
31
31
|
import { enforceBodyLimits, enforceFieldLimit, type BodyLimitsConfig } from './body-limits.js';
|
|
32
32
|
import { parseFormData } from './form-data.js';
|
|
33
33
|
import type { FormFlashData } from './form-flash.js';
|
|
34
|
+
import { checkVersionSkew, applyReloadHeaders } from './version-skew.js';
|
|
34
35
|
|
|
35
36
|
// ─── Types ────────────────────────────────────────────────────────────────
|
|
36
37
|
|
|
@@ -90,6 +91,21 @@ export async function handleActionRequest(
|
|
|
90
91
|
req: Request,
|
|
91
92
|
config: ActionDispatchConfig
|
|
92
93
|
): Promise<Response | FormRerender | null> {
|
|
94
|
+
// Version skew detection — reject actions from stale clients (TIM-446).
|
|
95
|
+
// On mismatch, return a structured RSC error response that the client
|
|
96
|
+
// handles by showing a brief "App updated" message and reloading.
|
|
97
|
+
const skewCheck = checkVersionSkew(req);
|
|
98
|
+
if (!skewCheck.ok) {
|
|
99
|
+
const reloadHeaders = new Headers({
|
|
100
|
+
'Content-Type': RSC_CONTENT_TYPE,
|
|
101
|
+
});
|
|
102
|
+
applyReloadHeaders(reloadHeaders);
|
|
103
|
+
// Return the reload signal as an RSC stream so createFromFetch can
|
|
104
|
+
// decode it. The client checks X-Timber-Reload before processing.
|
|
105
|
+
const rscStream = renderToReadableStream({ _versionSkew: true });
|
|
106
|
+
return new Response(rscStream, { status: 200, headers: reloadHeaders });
|
|
107
|
+
}
|
|
108
|
+
|
|
93
109
|
// CSRF validation — reject cross-origin mutation requests.
|
|
94
110
|
const csrfResult = validateCsrf(req, config.csrf);
|
|
95
111
|
if (!csrfResult.ok) {
|
|
@@ -39,11 +39,11 @@ export interface RequestContextStore {
|
|
|
39
39
|
/** Original (pre-overlay) frozen headers, kept for overlay merging. */
|
|
40
40
|
originalHeaders: Headers;
|
|
41
41
|
/**
|
|
42
|
-
* Promise resolving to the
|
|
43
|
-
*
|
|
44
|
-
*
|
|
42
|
+
* Promise resolving to the raw URLSearchParams for the current request.
|
|
43
|
+
* To get typed parsed params, import a search params definition and
|
|
44
|
+
* call `.parse(searchParams())`.
|
|
45
45
|
*/
|
|
46
|
-
searchParamsPromise: Promise<URLSearchParams
|
|
46
|
+
searchParamsPromise: Promise<URLSearchParams>;
|
|
47
47
|
/** Outgoing Set-Cookie entries (name → serialized value + options). Last write wins. */
|
|
48
48
|
cookieJar: Map<string, CookieEntry>;
|
|
49
49
|
/** Whether the response has flushed (headers committed). */
|
|
@@ -95,10 +95,10 @@ export function buildCssLinkTags(cssUrls: string[]): string {
|
|
|
95
95
|
* into 103 Early Hints responses. This avoids platform-specific 103
|
|
96
96
|
* sending code.
|
|
97
97
|
*
|
|
98
|
-
* Example output: `</assets/root.css>;
|
|
98
|
+
* Example output: `</assets/root.css>; as=style; rel=preload, </assets/page.css>; as=style; rel=preload`
|
|
99
99
|
*/
|
|
100
100
|
export function buildLinkHeaders(cssUrls: string[]): string {
|
|
101
|
-
return cssUrls.map((url) => `<${url}>;
|
|
101
|
+
return cssUrls.map((url) => `<${url}>; as=style; rel=preload`).join(', ');
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
// ─── Font utilities ──────────────────────────────────────────────────────
|
|
@@ -153,10 +153,10 @@ export function buildFontPreloadTags(fonts: ManifestFontEntry[]): string {
|
|
|
153
153
|
*
|
|
154
154
|
* Cloudflare CDN converts Link headers with rel=preload into 103 Early Hints.
|
|
155
155
|
*
|
|
156
|
-
* Example: `</fonts/inter.woff2>;
|
|
156
|
+
* Example: `</fonts/inter.woff2>; as=font; rel=preload; crossorigin`
|
|
157
157
|
*/
|
|
158
158
|
export function buildFontLinkHeaders(fonts: ManifestFontEntry[]): string {
|
|
159
|
-
return fonts.map((f) => `<${f.href}>;
|
|
159
|
+
return fonts.map((f) => `<${f.href}>; as=font; rel=preload; crossorigin`).join(', ');
|
|
160
160
|
}
|
|
161
161
|
|
|
162
162
|
// ─── JS chunk utilities ──────────────────────────────────────────────────
|
|
@@ -58,15 +58,31 @@ export interface EarlyHint {
|
|
|
58
58
|
/**
|
|
59
59
|
* Format a single EarlyHint as a Link header value.
|
|
60
60
|
*
|
|
61
|
+
* Attribute order: `as` before `rel` to match Cloudflare CDN's cached
|
|
62
|
+
* Early Hints format. Cloudflare caches Link headers from 200 responses
|
|
63
|
+
* and re-emits them as 103 Early Hints on subsequent requests. If our
|
|
64
|
+
* attribute order differs from Cloudflare's cached copy, the browser
|
|
65
|
+
* sees two preload headers for the same URL (different attribute order)
|
|
66
|
+
* and warns "Preload was ignored." Matching the order ensures the
|
|
67
|
+
* browser deduplicates them correctly.
|
|
68
|
+
*
|
|
61
69
|
* Examples:
|
|
62
|
-
* `</styles/root.css>;
|
|
63
|
-
* `</fonts/inter.woff2>;
|
|
70
|
+
* `</styles/root.css>; as=style; rel=preload`
|
|
71
|
+
* `</fonts/inter.woff2>; as=font; rel=preload; crossorigin=anonymous`
|
|
64
72
|
* `</_timber/client.js>; rel=modulepreload`
|
|
65
73
|
* `<https://fonts.googleapis.com>; rel=preconnect`
|
|
66
74
|
*/
|
|
67
75
|
export function formatLinkHeader(hint: EarlyHint): string {
|
|
76
|
+
// For preload hints, emit `as` before `rel` to match Cloudflare's
|
|
77
|
+
// cached header format and avoid duplicate preload warnings.
|
|
78
|
+
if (hint.as !== undefined) {
|
|
79
|
+
let value = `<${hint.href}>; as=${hint.as}; rel=${hint.rel}`;
|
|
80
|
+
if (hint.crossOrigin !== undefined) value += `; crossorigin=${hint.crossOrigin}`;
|
|
81
|
+
if (hint.fetchPriority !== undefined) value += `; fetchpriority=${hint.fetchPriority}`;
|
|
82
|
+
return value;
|
|
83
|
+
}
|
|
84
|
+
// For modulepreload / preconnect (no `as`), emit rel first.
|
|
68
85
|
let value = `<${hint.href}>; rel=${hint.rel}`;
|
|
69
|
-
if (hint.as !== undefined) value += `; as=${hint.as}`;
|
|
70
86
|
if (hint.crossOrigin !== undefined) value += `; crossorigin=${hint.crossOrigin}`;
|
|
71
87
|
if (hint.fetchPriority !== undefined) value += `; fetchpriority=${hint.fetchPriority}`;
|
|
72
88
|
return value;
|
|
@@ -84,8 +100,8 @@ export interface EarlyHintOptions {
|
|
|
84
100
|
* Collect all Link header strings for a matched route's segment chain.
|
|
85
101
|
*
|
|
86
102
|
* Walks the build manifest to emit hints for:
|
|
87
|
-
* - CSS stylesheets (
|
|
88
|
-
* - Font assets (
|
|
103
|
+
* - CSS stylesheets (as=style; rel=preload)
|
|
104
|
+
* - Font assets (as=font; rel=preload; crossorigin)
|
|
89
105
|
* - JS modulepreload hints (rel=modulepreload) — unless skipJs is set
|
|
90
106
|
*
|
|
91
107
|
* Also emits global CSS from the `_global` manifest key. Route files
|
|
@@ -94,7 +110,7 @@ export interface EarlyHintOptions {
|
|
|
94
110
|
* key contains all CSS assets from the client build — fine for early
|
|
95
111
|
* hints since they're just prefetch signals.
|
|
96
112
|
*
|
|
97
|
-
* Returns formatted Link header strings, deduplicated, root → leaf order.
|
|
113
|
+
* Returns formatted Link header strings, deduplicated by URL, root → leaf order.
|
|
98
114
|
* Returns an empty array in dev mode (manifest is empty).
|
|
99
115
|
*/
|
|
100
116
|
export function collectEarlyHintHeaders(
|
|
@@ -103,30 +119,35 @@ export function collectEarlyHintHeaders(
|
|
|
103
119
|
options?: EarlyHintOptions
|
|
104
120
|
): string[] {
|
|
105
121
|
const result: string[] = [];
|
|
106
|
-
|
|
122
|
+
// Dedup by URL (href), not by full formatted header string.
|
|
123
|
+
// Different code paths can produce the same URL with different attribute
|
|
124
|
+
// ordering, which would bypass a full-string dedup and produce duplicate
|
|
125
|
+
// Link headers that trigger browser "preload was ignored" warnings.
|
|
126
|
+
const seenUrls = new Set<string>();
|
|
107
127
|
|
|
108
|
-
const add = (header: string) => {
|
|
109
|
-
if (!
|
|
110
|
-
|
|
128
|
+
const add = (url: string, header: string) => {
|
|
129
|
+
if (!seenUrls.has(url)) {
|
|
130
|
+
seenUrls.add(url);
|
|
111
131
|
result.push(header);
|
|
112
132
|
}
|
|
113
133
|
};
|
|
114
134
|
|
|
115
|
-
// Per-route CSS —
|
|
135
|
+
// Per-route CSS — as=style; rel=preload
|
|
116
136
|
for (const url of collectRouteCss(segments, manifest)) {
|
|
117
|
-
add(formatLinkHeader({ href: url, rel: 'preload', as: 'style' }));
|
|
137
|
+
add(url, formatLinkHeader({ href: url, rel: 'preload', as: 'style' }));
|
|
118
138
|
}
|
|
119
139
|
|
|
120
140
|
// Global CSS — all CSS assets from the client bundle.
|
|
121
141
|
// Covers CSS that the RSC plugin injects via data-rsc-css-href,
|
|
122
142
|
// which isn't keyed to route segments in our manifest.
|
|
123
143
|
for (const url of manifest.css['_global'] ?? []) {
|
|
124
|
-
add(formatLinkHeader({ href: url, rel: 'preload', as: 'style' }));
|
|
144
|
+
add(url, formatLinkHeader({ href: url, rel: 'preload', as: 'style' }));
|
|
125
145
|
}
|
|
126
146
|
|
|
127
|
-
// Fonts —
|
|
147
|
+
// Fonts — as=font; rel=preload; crossorigin (crossorigin required per spec)
|
|
128
148
|
for (const font of collectRouteFonts(segments, manifest)) {
|
|
129
149
|
add(
|
|
150
|
+
font.href,
|
|
130
151
|
formatLinkHeader({ href: font.href, rel: 'preload', as: 'font', crossOrigin: 'anonymous' })
|
|
131
152
|
);
|
|
132
153
|
}
|
|
@@ -134,7 +155,7 @@ export function collectEarlyHintHeaders(
|
|
|
134
155
|
// JS chunks — rel=modulepreload (skip when client JS is disabled)
|
|
135
156
|
if (!options?.skipJs) {
|
|
136
157
|
for (const url of collectRouteModulepreloads(segments, manifest)) {
|
|
137
|
-
add(formatLinkHeader({ href: url, rel: 'modulepreload' }));
|
|
158
|
+
add(url, formatLinkHeader({ href: url, rel: 'modulepreload' }));
|
|
138
159
|
}
|
|
139
160
|
}
|
|
140
161
|
|
|
@@ -8,6 +8,20 @@
|
|
|
8
8
|
import { TimberErrorBoundary } from '#/client/error-boundary.js';
|
|
9
9
|
import type { ManifestSegmentNode } from './route-matcher.js';
|
|
10
10
|
|
|
11
|
+
/** MDX/markdown extensions — server components that cannot be passed as function props. */
|
|
12
|
+
const MDX_EXTENSIONS = new Set(['.mdx', '.md']);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Check if a manifest file path ends with an MDX/markdown extension.
|
|
16
|
+
* MDX components are server components and cannot cross the RSC→client
|
|
17
|
+
* boundary as function props to TimberErrorBoundary.
|
|
18
|
+
*/
|
|
19
|
+
function isMdxFilePath(filePath: string): boolean {
|
|
20
|
+
const dotIndex = filePath.lastIndexOf('.');
|
|
21
|
+
if (dotIndex === -1) return false;
|
|
22
|
+
return MDX_EXTENSIONS.has(filePath.slice(dotIndex));
|
|
23
|
+
}
|
|
24
|
+
|
|
11
25
|
/**
|
|
12
26
|
* Wrap an element in error boundaries defined by a route segment.
|
|
13
27
|
*
|
|
@@ -15,6 +29,10 @@ import type { ManifestSegmentNode } from './route-matcher.js';
|
|
|
15
29
|
* 1. Specific status files (e.g., 404.tsx, 500.tsx) — highest priority at runtime
|
|
16
30
|
* 2. Category catch-alls (4xx.tsx, 5xx.tsx)
|
|
17
31
|
* 3. error.tsx — catches anything not matched by status files
|
|
32
|
+
*
|
|
33
|
+
* MDX status files are server components and cannot be passed as function
|
|
34
|
+
* props to TimberErrorBoundary (a 'use client' component). Instead, they
|
|
35
|
+
* are pre-rendered as elements and passed as fallbackElement. See TIM-503.
|
|
18
36
|
*/
|
|
19
37
|
export async function wrapSegmentWithErrorBoundaries(
|
|
20
38
|
segment: ManifestSegmentNode,
|
|
@@ -29,11 +47,20 @@ export async function wrapSegmentWithErrorBoundaries(
|
|
|
29
47
|
if (!isNaN(status)) {
|
|
30
48
|
const mod = (await file.load()) as Record<string, unknown>;
|
|
31
49
|
if (mod.default) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
50
|
+
if (isMdxFilePath(file.filePath)) {
|
|
51
|
+
// MDX: pre-render as element (server component can't be a function prop)
|
|
52
|
+
element = h(TimberErrorBoundary, {
|
|
53
|
+
fallbackElement: h(mod.default as never, { status }),
|
|
54
|
+
status,
|
|
55
|
+
children: element,
|
|
56
|
+
});
|
|
57
|
+
} else {
|
|
58
|
+
element = h(TimberErrorBoundary, {
|
|
59
|
+
fallbackComponent: mod.default,
|
|
60
|
+
status,
|
|
61
|
+
children: element,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
37
64
|
}
|
|
38
65
|
}
|
|
39
66
|
}
|
|
@@ -44,11 +71,20 @@ export async function wrapSegmentWithErrorBoundaries(
|
|
|
44
71
|
if (key === '4xx' || key === '5xx') {
|
|
45
72
|
const mod = (await file.load()) as Record<string, unknown>;
|
|
46
73
|
if (mod.default) {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
74
|
+
const categoryStatus = key === '4xx' ? 400 : 500;
|
|
75
|
+
if (isMdxFilePath(file.filePath)) {
|
|
76
|
+
element = h(TimberErrorBoundary, {
|
|
77
|
+
fallbackElement: h(mod.default as never, {}),
|
|
78
|
+
status: categoryStatus,
|
|
79
|
+
children: element,
|
|
80
|
+
});
|
|
81
|
+
} else {
|
|
82
|
+
element = h(TimberErrorBoundary, {
|
|
83
|
+
fallbackComponent: mod.default,
|
|
84
|
+
status: categoryStatus,
|
|
85
|
+
children: element,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
52
88
|
}
|
|
53
89
|
}
|
|
54
90
|
}
|
|
@@ -58,10 +94,17 @@ export async function wrapSegmentWithErrorBoundaries(
|
|
|
58
94
|
if (segment.error) {
|
|
59
95
|
const mod = (await segment.error.load()) as Record<string, unknown>;
|
|
60
96
|
if (mod.default) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
97
|
+
if (isMdxFilePath(segment.error.filePath)) {
|
|
98
|
+
element = h(TimberErrorBoundary, {
|
|
99
|
+
fallbackElement: h(mod.default as never, {}),
|
|
100
|
+
children: element,
|
|
101
|
+
});
|
|
102
|
+
} else {
|
|
103
|
+
element = h(TimberErrorBoundary, {
|
|
104
|
+
fallbackComponent: mod.default,
|
|
105
|
+
children: element,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
65
108
|
}
|
|
66
109
|
}
|
|
67
110
|
|