@timber-js/app 0.2.0-alpha.145 → 0.2.0-alpha.147
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/{actions-DzFMnUAR.js → actions-OIZ8siQu.js} +28 -43
- package/dist/_chunks/actions-OIZ8siQu.js.map +1 -0
- package/dist/_chunks/{cli-schema-sync-DH-M8Poy.js → cli-schema-sync-CBiMEtqa.js} +1 -1
- package/dist/_chunks/{cli-schema-sync-D2IpXicg.js → cli-schema-sync-CQaxupHv.js} +47 -54
- package/dist/_chunks/cli-schema-sync-CQaxupHv.js.map +1 -0
- package/dist/_chunks/{handler-store-DjISKYEB.js → handler-store-BVDgyTh7.js} +26 -2
- package/dist/_chunks/handler-store-BVDgyTh7.js.map +1 -0
- package/dist/_chunks/{href-validation-EZktELp8.js → href-validation-DyZIzfMJ.js} +18 -2
- package/dist/_chunks/{href-validation-EZktELp8.js.map → href-validation-DyZIzfMJ.js.map} +1 -1
- package/dist/_chunks/{navigation-root-DLc4s4ZC.js → navigation-root-bUDZLw74.js} +23 -2
- package/dist/_chunks/navigation-root-bUDZLw74.js.map +1 -0
- package/dist/_chunks/tracing-M_zKubhk.js.map +1 -1
- package/dist/_chunks/{walkers-7FBPM-hX.js → walkers-Ck4Y45XN.js} +49 -8
- package/dist/_chunks/walkers-Ck4Y45XN.js.map +1 -0
- package/dist/adapters/cloudflare.d.ts +1 -15
- package/dist/adapters/cloudflare.d.ts.map +1 -1
- package/dist/adapters/cloudflare.js +10 -28
- package/dist/adapters/cloudflare.js.map +1 -1
- package/dist/adapters/compress-module.d.ts.map +1 -1
- package/dist/adapters/nitro.d.ts.map +1 -1
- package/dist/adapters/nitro.js +18 -5
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/cache/index.js +39 -12
- package/dist/cache/index.js.map +1 -1
- package/dist/cache/redis-handler.d.ts +72 -8
- package/dist/cache/redis-handler.d.ts.map +1 -1
- package/dist/cache/stable-stringify.d.ts +9 -0
- package/dist/cache/stable-stringify.d.ts.map +1 -1
- package/dist/cache/timber-cache.d.ts +3 -1
- package/dist/cache/timber-cache.d.ts.map +1 -1
- package/dist/cli.js +1 -1
- package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
- package/dist/client/browser-entry/router-init.d.ts.map +1 -1
- package/dist/client/browser-entry/scroll.d.ts +9 -0
- package/dist/client/browser-entry/scroll.d.ts.map +1 -1
- package/dist/client/error-boundary.d.ts.map +1 -1
- package/dist/client/error-boundary.js +24 -10
- package/dist/client/error-boundary.js.map +1 -1
- package/dist/client/error-reconstituter.d.ts +8 -1
- package/dist/client/error-reconstituter.d.ts.map +1 -1
- package/dist/client/index.js +3 -3
- package/dist/client/index.js.map +1 -1
- package/dist/client/internal.js +99 -24
- package/dist/client/internal.js.map +1 -1
- package/dist/client/link.d.ts.map +1 -1
- package/dist/client/navigation-api.d.ts.map +1 -1
- package/dist/client/navigation-root.d.ts +13 -0
- package/dist/client/navigation-root.d.ts.map +1 -1
- package/dist/client/router.d.ts +7 -0
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/rsc-fetch.d.ts.map +1 -1
- package/dist/dev-tools/404-page.d.ts +6 -2
- package/dist/dev-tools/404-page.d.ts.map +1 -1
- package/dist/dev-tools/dev-page-shell.d.ts +77 -0
- package/dist/dev-tools/dev-page-shell.d.ts.map +1 -0
- package/dist/dev-tools/error-page.d.ts +4 -30
- package/dist/dev-tools/error-page.d.ts.map +1 -1
- package/dist/dev-tools/index.d.ts +2 -1
- package/dist/dev-tools/index.d.ts.map +1 -1
- package/dist/dev-tools/instrumentation.d.ts +11 -1
- package/dist/dev-tools/instrumentation.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +432 -255
- package/dist/index.js.map +1 -1
- package/dist/plugins/cache.d.ts +43 -9
- package/dist/plugins/cache.d.ts.map +1 -1
- package/dist/plugins/cloudflare-wasm.d.ts +4 -1
- package/dist/plugins/cloudflare-wasm.d.ts.map +1 -1
- package/dist/plugins/dev-server.d.ts +16 -0
- package/dist/plugins/dev-server.d.ts.map +1 -1
- package/dist/plugins/server-bundle.d.ts.map +1 -1
- package/dist/plugins/shims.d.ts +23 -0
- package/dist/plugins/shims.d.ts.map +1 -1
- package/dist/routing/codegen-types.d.ts +4 -0
- package/dist/routing/codegen-types.d.ts.map +1 -1
- package/dist/routing/index.js +2 -2
- package/dist/server/action-handler.d.ts.map +1 -1
- package/dist/server/actions.d.ts +3 -2
- package/dist/server/actions.d.ts.map +1 -1
- package/dist/server/cookie-context.d.ts.map +1 -1
- package/dist/server/cookie-parsing.d.ts.map +1 -1
- package/dist/server/deny-boundary.d.ts +27 -2
- package/dist/server/deny-boundary.d.ts.map +1 -1
- package/dist/server/deny-renderer.d.ts.map +1 -1
- package/dist/server/error-boundary-wrapper.d.ts.map +1 -1
- package/dist/server/fallback-error.d.ts +2 -2
- package/dist/server/fallback-error.d.ts.map +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/internal.js +227 -179
- package/dist/server/internal.js.map +1 -1
- package/dist/server/node-stream-transforms.d.ts +24 -6
- package/dist/server/node-stream-transforms.d.ts.map +1 -1
- package/dist/server/pipeline.d.ts +11 -0
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/primitives.d.ts +1 -11
- package/dist/server/primitives.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/rsc-entry/rsc-payload.d.ts.map +1 -1
- package/dist/server/slot-resolver.d.ts +6 -4
- package/dist/server/slot-resolver.d.ts.map +1 -1
- package/dist/server/ssr-entry.d.ts.map +1 -1
- package/dist/server/status-code-resolver.d.ts +6 -0
- package/dist/server/status-code-resolver.d.ts.map +1 -1
- package/dist/server/tracing.d.ts +2 -1
- package/dist/server/tracing.d.ts.map +1 -1
- package/dist/server/utils/mdx-file.d.ts +15 -0
- package/dist/server/utils/mdx-file.d.ts.map +1 -0
- package/dist/shared/head-header.d.ts +54 -0
- package/dist/shared/head-header.d.ts.map +1 -0
- package/dist/shared/href-validation.d.ts +11 -0
- package/dist/shared/href-validation.d.ts.map +1 -1
- package/dist/shared/redirect-type.d.ts +17 -0
- package/dist/shared/redirect-type.d.ts.map +1 -0
- package/dist/shims/headers.d.ts +52 -1
- package/dist/shims/headers.d.ts.map +1 -1
- package/dist/shims/navigation-client.d.ts +33 -11
- package/dist/shims/navigation-client.d.ts.map +1 -1
- package/package.json +11 -10
- package/src/adapters/cloudflare.ts +13 -47
- package/src/adapters/compress-module.ts +9 -3
- package/src/adapters/nitro.ts +9 -2
- package/src/cache/redis-handler.ts +77 -13
- package/src/cache/stable-stringify.ts +65 -2
- package/src/cache/timber-cache.ts +25 -13
- package/src/cli.ts +0 -0
- package/src/client/browser-entry/action-dispatch.ts +7 -2
- package/src/client/browser-entry/router-init.ts +2 -1
- package/src/client/browser-entry/scroll.ts +23 -0
- package/src/client/error-boundary.tsx +33 -5
- package/src/client/error-reconstituter.tsx +15 -1
- package/src/client/link.tsx +4 -1
- package/src/client/navigation-api.ts +5 -1
- package/src/client/navigation-root.tsx +16 -0
- package/src/client/router.ts +107 -29
- package/src/client/rsc-fetch.ts +6 -6
- package/src/dev-tools/404-page.ts +22 -123
- package/src/dev-tools/dev-page-shell.ts +256 -0
- package/src/dev-tools/error-page.ts +27 -203
- package/src/dev-tools/index.ts +7 -1
- package/src/dev-tools/instrumentation.ts +39 -2
- package/src/index.ts +1 -0
- package/src/plugins/cache.ts +79 -21
- package/src/plugins/cloudflare-wasm.ts +48 -10
- package/src/plugins/dev-server.ts +32 -8
- package/src/plugins/server-bundle.ts +25 -9
- package/src/plugins/shims.ts +94 -52
- package/src/routing/codegen-types.ts +4 -0
- package/src/routing/codegen.ts +63 -6
- package/src/routing/scanner.ts +80 -63
- package/src/server/action-handler.ts +10 -2
- package/src/server/actions.ts +3 -2
- package/src/server/compress.ts +11 -2
- package/src/server/cookie-context.ts +11 -0
- package/src/server/cookie-parsing.ts +16 -4
- package/src/server/deny-boundary.ts +106 -13
- package/src/server/deny-renderer.ts +40 -10
- package/src/server/error-boundary-wrapper.ts +1 -14
- package/src/server/fallback-error.ts +9 -4
- package/src/server/node-stream-transforms.ts +61 -24
- package/src/server/pipeline.ts +12 -0
- package/src/server/primitives.ts +10 -31
- package/src/server/route-matcher.ts +22 -6
- package/src/server/rsc-entry/error-renderer.ts +1 -12
- package/src/server/rsc-entry/index.ts +7 -2
- package/src/server/rsc-entry/rsc-payload.ts +4 -3
- package/src/server/slot-resolver.ts +27 -14
- package/src/server/ssr-entry.ts +17 -28
- package/src/server/status-code-resolver.ts +3 -2
- package/src/server/tracing.ts +17 -1
- package/src/server/tree-match.ts +15 -5
- package/src/server/utils/mdx-file.ts +22 -0
- package/src/shared/head-header.ts +69 -0
- package/src/shared/href-validation.ts +24 -0
- package/src/shared/redirect-type.ts +17 -0
- package/src/shims/headers.ts +119 -2
- package/src/shims/navigation-client.ts +48 -27
- package/LICENSE +0 -8
- package/dist/_chunks/actions-DzFMnUAR.js.map +0 -1
- package/dist/_chunks/cli-schema-sync-D2IpXicg.js.map +0 -1
- package/dist/_chunks/handler-store-DjISKYEB.js.map +0 -1
- package/dist/_chunks/navigation-root-DLc4s4ZC.js.map +0 -1
- package/dist/_chunks/walkers-7FBPM-hX.js.map +0 -1
package/src/server/tracing.ts
CHANGED
|
@@ -94,6 +94,16 @@ export function getTraceStore(): TraceStore | undefined {
|
|
|
94
94
|
|
|
95
95
|
// ─── Dev-Mode OTEL Auto-Init ─────────────────────────────────────────────
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Well-known key marking dev tracing as initialized.
|
|
99
|
+
*
|
|
100
|
+
* The RSC entry module re-evaluates on every HMR invalidation and calls
|
|
101
|
+
* initDevTracing() again — without this guard, each call would register a
|
|
102
|
+
* fresh provider/processor and duplicate every span's output. Symbol.for()
|
|
103
|
+
* survives module re-evaluation. See TIM-1067, B49.
|
|
104
|
+
*/
|
|
105
|
+
const DEV_TRACING_INIT_KEY = Symbol.for('timber.dev.tracing-initialized');
|
|
106
|
+
|
|
97
107
|
/**
|
|
98
108
|
* Initialize a minimal OTEL SDK in dev mode so spans are recorded and
|
|
99
109
|
* fed to the DevSpanProcessor for dev log output.
|
|
@@ -102,11 +112,15 @@ export function getTraceStore(): TraceStore | undefined {
|
|
|
102
112
|
* our DevSpanProcessor alongside theirs. If no SDK is configured,
|
|
103
113
|
* we create a BasicTracerProvider with our processor.
|
|
104
114
|
*
|
|
105
|
-
*
|
|
115
|
+
* Idempotent across module re-evaluations (HMR) — only the first call
|
|
116
|
+
* registers. Only called in dev mode — zero overhead in production.
|
|
106
117
|
*/
|
|
107
118
|
export async function initDevTracing(
|
|
108
119
|
config: import('../dev-tools/logger.js').DevLoggerConfig
|
|
109
120
|
): Promise<void> {
|
|
121
|
+
const globals = globalThis as Record<symbol, unknown>;
|
|
122
|
+
if (globals[DEV_TRACING_INIT_KEY]) return;
|
|
123
|
+
|
|
110
124
|
const api = await getOtelApi();
|
|
111
125
|
if (!api) return;
|
|
112
126
|
|
|
@@ -145,6 +159,8 @@ export async function initDevTracing(
|
|
|
145
159
|
|
|
146
160
|
// Reset cached tracer so next getTracer() picks up the new provider
|
|
147
161
|
_tracer = undefined;
|
|
162
|
+
|
|
163
|
+
globals[DEV_TRACING_INIT_KEY] = true;
|
|
148
164
|
}
|
|
149
165
|
|
|
150
166
|
// ─── OTEL Span Helpers ───────────────────────────────────────────────────
|
package/src/server/tree-match.ts
CHANGED
|
@@ -142,7 +142,7 @@ function matchNode<TFile>(
|
|
|
142
142
|
// with no path segments.
|
|
143
143
|
const effective = collectEffectiveChildren(node);
|
|
144
144
|
for (const { child, groupChain } of effective) {
|
|
145
|
-
if (child.segmentType === 'optional-catch-all' && isTerminal(child)) {
|
|
145
|
+
if (child.segmentType === 'optional-catch-all' && (!requireTerminal || isTerminal(child))) {
|
|
146
146
|
chain.push(...groupChain, child);
|
|
147
147
|
return true;
|
|
148
148
|
}
|
|
@@ -187,18 +187,28 @@ function matchNode<TFile>(
|
|
|
187
187
|
}
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
-
// 3. Catch-all segments ([...param]) — must be terminal
|
|
190
|
+
// 3. Catch-all segments ([...param]) — must be terminal unless the
|
|
191
|
+
// caller passed requireTerminal:false (metadata route matching needs to
|
|
192
|
+
// find the owning segment even when it has no page).
|
|
191
193
|
for (const { child, groupChain } of effective) {
|
|
192
|
-
if (
|
|
194
|
+
if (
|
|
195
|
+
child.segmentType === 'catch-all' &&
|
|
196
|
+
child.paramName &&
|
|
197
|
+
(!requireTerminal || isTerminal(child))
|
|
198
|
+
) {
|
|
193
199
|
chain.push(...groupChain, child);
|
|
194
200
|
params[child.paramName] = parts.slice(index);
|
|
195
201
|
return true;
|
|
196
202
|
}
|
|
197
203
|
}
|
|
198
204
|
|
|
199
|
-
// 4. Optional catch-all segments ([[...param]]) —
|
|
205
|
+
// 4. Optional catch-all segments ([[...param]]) — same terminal rule
|
|
200
206
|
for (const { child, groupChain } of effective) {
|
|
201
|
-
if (
|
|
207
|
+
if (
|
|
208
|
+
child.segmentType === 'optional-catch-all' &&
|
|
209
|
+
child.paramName &&
|
|
210
|
+
(!requireTerminal || isTerminal(child))
|
|
211
|
+
) {
|
|
202
212
|
chain.push(...groupChain, child);
|
|
203
213
|
params[child.paramName] = parts.slice(index);
|
|
204
214
|
return true;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MDX file-path detection shared by error/deny rendering paths.
|
|
3
|
+
*
|
|
4
|
+
* MDX status and error files are server components — they cannot be passed
|
|
5
|
+
* as function props across the RSC→client boundary (e.g. to
|
|
6
|
+
* TimberErrorBoundary or ErrorReconstituter) and must be pre-rendered as
|
|
7
|
+
* elements with plain props instead.
|
|
8
|
+
*
|
|
9
|
+
* See design/10-error-handling.md §"Status-Code File Variants".
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** MDX/markdown extensions — server components by default. */
|
|
13
|
+
const MDX_EXTENSIONS = new Set(['.mdx', '.md']);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Check if a file path ends with an MDX/markdown extension.
|
|
17
|
+
*/
|
|
18
|
+
export function isMdxFilePath(filePath: string): boolean {
|
|
19
|
+
const dotIndex = filePath.lastIndexOf('.');
|
|
20
|
+
if (dotIndex === -1) return false;
|
|
21
|
+
return MDX_EXTENSIONS.has(filePath.slice(dotIndex));
|
|
22
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared encode/decode for the X-Timber-Head response header.
|
|
3
|
+
*
|
|
4
|
+
* Head elements (title, meta, link) are forwarded to the client as a JSON
|
|
5
|
+
* payload in a response header on both the navigation path (RSC payload
|
|
6
|
+
* responses) and the action-revalidation path. HTTP header values must be
|
|
7
|
+
* ByteStrings — any character above U+00FF (em dash, curly quotes, emoji,
|
|
8
|
+
* CJK) makes `new Response(...)` throw. Percent-encoding via
|
|
9
|
+
* encodeURIComponent keeps the value ASCII-safe.
|
|
10
|
+
*
|
|
11
|
+
* This module is the single source of truth for the wire format. Both
|
|
12
|
+
* server emit sites (rsc-entry/rsc-payload.ts, action-handler.ts) and both
|
|
13
|
+
* client parse sites (rsc-fetch.ts, browser-entry/action-dispatch.ts) MUST
|
|
14
|
+
* use it — changing one side alone breaks the other. See TIM-1058.
|
|
15
|
+
*
|
|
16
|
+
* Isomorphic: no server or client imports allowed here.
|
|
17
|
+
*
|
|
18
|
+
* See design/16-metadata.md and design/08-forms-and-actions.md.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Header name for resolved head elements. */
|
|
22
|
+
export const HEAD_HEADER = 'X-Timber-Head';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Maximum encoded payload size in characters (ASCII after encoding, so
|
|
26
|
+
* characters === bytes). Headers aren't meant for unbounded payloads;
|
|
27
|
+
* oversized head metadata is skipped rather than truncated — the client
|
|
28
|
+
* keeps its current head, which degrades gracefully.
|
|
29
|
+
*/
|
|
30
|
+
export const HEAD_HEADER_MAX_LENGTH = 4096;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A rendered head element descriptor — the structural shape on the wire.
|
|
34
|
+
* Matches the server's `HeadElement` (route-element-builder.ts); the client
|
|
35
|
+
* narrows `tag` to 'title' | 'meta' | 'link' when applying to the DOM.
|
|
36
|
+
*/
|
|
37
|
+
export interface HeadElementPayload {
|
|
38
|
+
tag: string;
|
|
39
|
+
content?: string;
|
|
40
|
+
attrs?: Record<string, string | null>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Encode head elements for the X-Timber-Head header.
|
|
45
|
+
*
|
|
46
|
+
* Returns the percent-encoded JSON string, or null when the encoded
|
|
47
|
+
* payload exceeds HEAD_HEADER_MAX_LENGTH (caller skips the header).
|
|
48
|
+
*/
|
|
49
|
+
export function encodeHeadHeader(headElements: HeadElementPayload[]): string | null {
|
|
50
|
+
const encoded = encodeURIComponent(JSON.stringify(headElements));
|
|
51
|
+
return encoded.length <= HEAD_HEADER_MAX_LENGTH ? encoded : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Decode an X-Timber-Head header value back into head elements.
|
|
56
|
+
*
|
|
57
|
+
* Returns null when the value is missing or malformed (corrupted
|
|
58
|
+
* percent-encoding or invalid JSON) — callers treat null as "no head
|
|
59
|
+
* update" rather than failing the navigation or action.
|
|
60
|
+
*/
|
|
61
|
+
export function decodeHeadHeader(header: string | null): HeadElementPayload[] | null {
|
|
62
|
+
if (!header) return null;
|
|
63
|
+
try {
|
|
64
|
+
const parsed = JSON.parse(decodeURIComponent(header));
|
|
65
|
+
return Array.isArray(parsed) ? parsed : null;
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -80,6 +80,30 @@ export function isInternalHref(href: string): boolean {
|
|
|
80
80
|
return true;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
/** Pattern matching absolute URLs: any scheme (http:, javascript:, …) or protocol-relative // */
|
|
84
|
+
const ABSOLUTE_URL_RE = /^(?:[a-zA-Z][a-zA-Z\d+\-.]*:|\/\/)/;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Assert that a redirect target is a relative path — rejects absolute and
|
|
88
|
+
* protocol-relative URLs. Used by both the server `redirect()` primitive and
|
|
89
|
+
* the client next/navigation shim so they enforce identical semantics.
|
|
90
|
+
*
|
|
91
|
+
* Normalizes for validation only: strips C0 controls, trims, then replaces
|
|
92
|
+
* backslashes with forward slashes. Browsers normalize \ → / during URL
|
|
93
|
+
* resolution, so "/\evil.com" becomes "//evil.com" (protocol-relative open
|
|
94
|
+
* redirect). See design/13-security.md tests #8, #8a–#8c.
|
|
95
|
+
*/
|
|
96
|
+
export function assertRelativeRedirectPath(path: string): void {
|
|
97
|
+
const cleaned = path.replace(CONTROL_CHARS_RE, '').trimStart().replaceAll('\\', '/');
|
|
98
|
+
|
|
99
|
+
if (ABSOLUTE_URL_RE.test(cleaned)) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`redirect() only accepts relative URLs. Got absolute URL: "${path}". ` +
|
|
102
|
+
'Use redirectExternal(url, allowList) for external redirects.'
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
83
107
|
const SAFE_REDIRECT_SCHEMES = new Set(['http:', 'https:']);
|
|
84
108
|
|
|
85
109
|
/**
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Next.js redirect type discriminator.
|
|
3
|
+
*
|
|
4
|
+
* Provided for API compatibility with libraries that import `RedirectType`
|
|
5
|
+
* from `next/navigation`. In timber, `redirect()` always uses `replace`
|
|
6
|
+
* semantics (no history entry for the redirect itself).
|
|
7
|
+
*
|
|
8
|
+
* Lives in shared/ (isomorphic) so both the server primitives and the
|
|
9
|
+
* client-only next/navigation shim export the same definition without the
|
|
10
|
+
* client shim pulling in server code.
|
|
11
|
+
*/
|
|
12
|
+
export const RedirectType = {
|
|
13
|
+
push: 'push',
|
|
14
|
+
replace: 'replace',
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
export type RedirectTypeValue = (typeof RedirectType)[keyof typeof RedirectType];
|
package/src/shims/headers.ts
CHANGED
|
@@ -4,10 +4,127 @@
|
|
|
4
4
|
* Imports from @timber-js/app/server which Vite resolves to dist/server/index.js
|
|
5
5
|
* via native package.json exports. This ensures the same ALS singleton as the
|
|
6
6
|
* pipeline (both import from the same shared request-context chunk in dist/).
|
|
7
|
+
*
|
|
8
|
+
* `cookies()` is NOT a bare alias of `getCookieJar()`: Next.js's
|
|
9
|
+
* RequestCookies returns `{ name, value }` RequestCookie objects from
|
|
10
|
+
* `.get()`/`.getAll()`, while timber's native jar returns bare strings from
|
|
11
|
+
* `.get()`. Ecosystem libraries written against the real API use
|
|
12
|
+
* `(await cookies()).get('session')?.value` — with the bare-string shape
|
|
13
|
+
* that silently reads `undefined` off a string. The adapter below presents
|
|
14
|
+
* the Next shape while delegating every operation to the timber jar, so the
|
|
15
|
+
* encoding contract, cookie-octet validation, and mutable-context checks
|
|
16
|
+
* (design/29-cookies.md, ONGOING_SECURITY.md H-3) apply unchanged.
|
|
17
|
+
* See TIM-1042 and design/14-ecosystem.md §next/headers.
|
|
7
18
|
*/
|
|
8
19
|
|
|
20
|
+
import { getCookieJar } from '@timber-js/app/server';
|
|
21
|
+
import type { CookieOptions, SetCookieOptions } from '@timber-js/app/server';
|
|
22
|
+
|
|
9
23
|
// Re-export timber's primary API
|
|
10
24
|
export { getHeaders, getCookieJar } from '@timber-js/app/server';
|
|
11
25
|
|
|
12
|
-
// Next.js compat
|
|
13
|
-
export { getHeaders as headers
|
|
26
|
+
// Next.js compat alias — libraries importing from 'next/headers' expect this name.
|
|
27
|
+
export { getHeaders as headers } from '@timber-js/app/server';
|
|
28
|
+
|
|
29
|
+
/** Next.js's request cookie shape: `cookies().get(name)` → `{ name, value }`. */
|
|
30
|
+
export interface RequestCookie {
|
|
31
|
+
name: string;
|
|
32
|
+
value: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Next.js's `cookies().set()` options — `expires` may be a ms timestamp. */
|
|
36
|
+
export interface NextCookieOptions extends Omit<SetCookieOptions, 'expires'> {
|
|
37
|
+
expires?: Date | number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Next.js's object overload for `cookies().set({ name, value, ...options })`. */
|
|
41
|
+
export interface NextCookieListItem extends NextCookieOptions {
|
|
42
|
+
name: string;
|
|
43
|
+
value: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Next.js's RequestCookies surface, backed by timber's cookie jar. */
|
|
47
|
+
export interface NextRequestCookies {
|
|
48
|
+
get(name: string): RequestCookie | undefined;
|
|
49
|
+
get(cookie: RequestCookie): RequestCookie | undefined;
|
|
50
|
+
getAll(name?: string): RequestCookie[];
|
|
51
|
+
getAll(cookie: RequestCookie): RequestCookie[];
|
|
52
|
+
has(name: string): boolean;
|
|
53
|
+
readonly size: number;
|
|
54
|
+
set(name: string, value: string, options?: NextCookieOptions): void;
|
|
55
|
+
set(cookie: NextCookieListItem): void;
|
|
56
|
+
delete(name: string): void;
|
|
57
|
+
delete(cookie: { name: string } & Pick<CookieOptions, 'path' | 'domain'>): void;
|
|
58
|
+
[Symbol.iterator](): IterableIterator<[string, RequestCookie]>;
|
|
59
|
+
toString(): string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Normalize Next's `expires?: Date | number` (ms timestamp) to timber's `Date`. */
|
|
63
|
+
function normalizeOptions(options?: NextCookieOptions): SetCookieOptions | undefined {
|
|
64
|
+
if (!options) return undefined;
|
|
65
|
+
const { expires, ...rest } = options;
|
|
66
|
+
if (typeof expires === 'number') return { ...rest, expires: new Date(expires) };
|
|
67
|
+
return options as SetCookieOptions;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Next.js compat: `cookies()` returning the RequestCookies shape.
|
|
72
|
+
*
|
|
73
|
+
* Next.js 15 makes `cookies()` async; awaiting this sync return value works
|
|
74
|
+
* for both the `(await cookies()).get()` and legacy `cookies().get()` call
|
|
75
|
+
* styles, so libraries targeting either Next version resolve correctly.
|
|
76
|
+
*/
|
|
77
|
+
export function cookies(): NextRequestCookies {
|
|
78
|
+
const jar = getCookieJar();
|
|
79
|
+
|
|
80
|
+
const toCookie = (name: string): RequestCookie | undefined => {
|
|
81
|
+
const value = jar.get(name);
|
|
82
|
+
return value === undefined ? undefined : { name, value };
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
get(nameOrCookie: string | RequestCookie): RequestCookie | undefined {
|
|
87
|
+
return toCookie(typeof nameOrCookie === 'string' ? nameOrCookie : nameOrCookie.name);
|
|
88
|
+
},
|
|
89
|
+
getAll(nameOrCookie?: string | RequestCookie): RequestCookie[] {
|
|
90
|
+
const all = jar.getAll();
|
|
91
|
+
if (nameOrCookie === undefined) return all;
|
|
92
|
+
const name = typeof nameOrCookie === 'string' ? nameOrCookie : nameOrCookie.name;
|
|
93
|
+
return all.filter((cookie) => cookie.name === name);
|
|
94
|
+
},
|
|
95
|
+
has(name: string): boolean {
|
|
96
|
+
return jar.has(name);
|
|
97
|
+
},
|
|
98
|
+
get size(): number {
|
|
99
|
+
return jar.size;
|
|
100
|
+
},
|
|
101
|
+
set(
|
|
102
|
+
nameOrCookie: string | NextCookieListItem,
|
|
103
|
+
value?: string,
|
|
104
|
+
options?: NextCookieOptions
|
|
105
|
+
): void {
|
|
106
|
+
if (typeof nameOrCookie === 'string') {
|
|
107
|
+
jar.set(nameOrCookie, value as string, normalizeOptions(options));
|
|
108
|
+
} else {
|
|
109
|
+
const { name, value: cookieValue, ...objectOptions } = nameOrCookie;
|
|
110
|
+
jar.set(name, cookieValue, normalizeOptions(objectOptions));
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
delete(nameOrCookie: string | ({ name: string } & Pick<CookieOptions, 'path' | 'domain'>)) {
|
|
114
|
+
if (typeof nameOrCookie === 'string') {
|
|
115
|
+
jar.delete(nameOrCookie);
|
|
116
|
+
} else {
|
|
117
|
+
const { name, ...options } = nameOrCookie;
|
|
118
|
+
jar.delete(name, options);
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
*[Symbol.iterator](): IterableIterator<[string, RequestCookie]> {
|
|
122
|
+
for (const cookie of jar.getAll()) {
|
|
123
|
+
yield [cookie.name, cookie];
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
toString(): string {
|
|
127
|
+
return jar.toString();
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shim: next/navigation (client environment only)
|
|
3
3
|
*
|
|
4
|
-
* Re-exports
|
|
5
|
-
*
|
|
6
|
-
* server/primitives.ts
|
|
4
|
+
* Re-exports the client-side hooks from timber's client API, and implements
|
|
5
|
+
* client-render `redirect()` / `permanentRedirect()` / `notFound()` without
|
|
6
|
+
* pulling server/primitives.ts into the browser bundle.
|
|
7
|
+
*
|
|
8
|
+
* Next.js supports calling redirect()/notFound() during client component
|
|
9
|
+
* render. timber matches that by throwing a plain Error carrying the same
|
|
10
|
+
* JSON digest the server pipeline attaches to RedirectSignal/DenySignal.
|
|
11
|
+
* TimberErrorBoundary recognizes the digest and converts it to an SPA
|
|
12
|
+
* navigation (redirect) or a status-code fallback (notFound), and
|
|
13
|
+
* browser-dev's isFrameworkSignalError() keeps it out of the dev overlay.
|
|
7
14
|
*
|
|
8
15
|
* The full shim (navigation.ts) is still used in the RSC and SSR environments
|
|
9
|
-
* where
|
|
16
|
+
* where the real server primitives produce proper HTTP responses.
|
|
10
17
|
*
|
|
11
18
|
* Imports use @timber-js/app/client (not #/ subpath imports) so they resolve
|
|
12
19
|
* to the same module instances as user code in Vite dev — preventing module
|
|
@@ -16,9 +23,12 @@
|
|
|
16
23
|
* See design/14-ecosystem.md §"next/navigation" for the full shim audit.
|
|
17
24
|
*/
|
|
18
25
|
|
|
26
|
+
import { assertRelativeRedirectPath } from '../shared/href-validation.js';
|
|
27
|
+
import type { RedirectTypeValue } from '../shared/redirect-type.js';
|
|
28
|
+
|
|
19
29
|
// Hooks — useSegmentParams and useSearchParams import from direct modules
|
|
20
30
|
// since they're no longer in @timber-js/app/client public exports.
|
|
21
|
-
export { useSegmentParams } from '../client/use-segment-params.js';
|
|
31
|
+
export { useSegmentParams, useSegmentParams as useParams } from '../client/use-segment-params.js';
|
|
22
32
|
export { useSearchParams } from '../client/use-search-params.js';
|
|
23
33
|
export {
|
|
24
34
|
usePathname,
|
|
@@ -27,32 +37,43 @@ export {
|
|
|
27
37
|
useSelectedLayoutSegments,
|
|
28
38
|
} from '@timber-js/app/client';
|
|
29
39
|
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
export const RedirectType = {
|
|
33
|
-
push: 'push',
|
|
34
|
-
replace: 'replace',
|
|
35
|
-
} as const;
|
|
40
|
+
// Single source of truth shared with server/primitives.ts.
|
|
41
|
+
export { RedirectType } from '../shared/redirect-type.js';
|
|
36
42
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
throw
|
|
42
|
-
'redirect() is a server-only function and cannot be called from client components. ' +
|
|
43
|
-
'Use useRouter().push() or useRouter().replace() for client-side navigation.'
|
|
44
|
-
);
|
|
43
|
+
/** Throw a plain Error carrying a framework signal digest. */
|
|
44
|
+
function throwSignal(message: string, digest: Record<string, unknown>): never {
|
|
45
|
+
const err = new Error(message);
|
|
46
|
+
(err as Error & { digest?: string }).digest = JSON.stringify(digest);
|
|
47
|
+
throw err;
|
|
45
48
|
}
|
|
46
49
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Client-render redirect. Throws a redirect-digest error that the nearest
|
|
52
|
+
* TimberErrorBoundary converts into an SPA navigation (replace semantics).
|
|
53
|
+
*
|
|
54
|
+
* The optional RedirectType argument is accepted for Next.js API
|
|
55
|
+
* compatibility but ignored — timber always uses replace semantics,
|
|
56
|
+
* matching the server-side redirect().
|
|
57
|
+
*/
|
|
58
|
+
export function redirect(path: string, _type?: RedirectTypeValue): never {
|
|
59
|
+
assertRelativeRedirectPath(path);
|
|
60
|
+
throwSignal(`Redirect to ${path}`, { type: 'redirect', location: path, status: 302 });
|
|
52
61
|
}
|
|
53
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Client-render permanent redirect. On the client there is no HTTP status —
|
|
65
|
+
* this behaves identically to redirect() apart from the digest's status.
|
|
66
|
+
*/
|
|
67
|
+
export function permanentRedirect(path: string, _type?: RedirectTypeValue): never {
|
|
68
|
+
assertRelativeRedirectPath(path);
|
|
69
|
+
throwSignal(`Redirect to ${path}`, { type: 'redirect', location: path, status: 308 });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Client-render notFound. Throws a deny-digest error (status 404) that the
|
|
74
|
+
* nearest matching TimberErrorBoundary renders as the 404 fallback. The
|
|
75
|
+
* message matches DenySignal's so digest-less fallback parsing still works.
|
|
76
|
+
*/
|
|
54
77
|
export function notFound(): never {
|
|
55
|
-
|
|
56
|
-
'notFound() is a server-only function and cannot be called from client components.'
|
|
57
|
-
);
|
|
78
|
+
throwSignal('Access denied with status 404', { type: 'deny', status: 404, data: null });
|
|
58
79
|
}
|
package/LICENSE
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
DONTFUCKINGUSE LICENSE
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2025 Daniel Saewitz
|
|
4
|
-
|
|
5
|
-
This software may not be used, copied, modified, merged, published,
|
|
6
|
-
distributed, sublicensed, or sold by anyone other than the copyright holder.
|
|
7
|
-
|
|
8
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
|