@timber-js/app 0.2.0-alpha.134 → 0.2.0-alpha.135
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-B8Fhsosm.js → actions-CHFblsdT.js} +13 -9
- package/dist/_chunks/actions-CHFblsdT.js.map +1 -0
- package/dist/_chunks/{build-output-helper-K7oUCRqU.js → build-output-helper-CfBfOU9m.js} +1 -1
- package/dist/_chunks/{build-output-helper-K7oUCRqU.js.map → build-output-helper-CfBfOU9m.js.map} +1 -1
- package/dist/_chunks/{codec-registry-DqkeAydt.js → codec-registry-G9hD5LOq.js} +1 -1
- package/dist/_chunks/{codec-registry-DqkeAydt.js.map → codec-registry-G9hD5LOq.js.map} +1 -1
- package/dist/_chunks/{define-jeHDXZGq.js → define-DXWUJb-K.js} +3 -3
- package/dist/_chunks/{define-jeHDXZGq.js.map → define-DXWUJb-K.js.map} +1 -1
- package/dist/_chunks/{define-DMq-Yjp7.js → define-Dp-qu3ql.js} +3 -3
- package/dist/_chunks/{define-DMq-Yjp7.js.map → define-Dp-qu3ql.js.map} +1 -1
- package/dist/_chunks/fast-hash-DXZ7S2D5.js +36 -0
- package/dist/_chunks/fast-hash-DXZ7S2D5.js.map +1 -0
- package/dist/_chunks/{format-BjN9HLu9.js → format-CR6v1pWE.js} +1 -1
- package/dist/_chunks/{format-BjN9HLu9.js.map → format-CR6v1pWE.js.map} +1 -1
- package/dist/_chunks/{handler-store-bIAstrxQ.js → handler-store-aQMpqPe7.js} +1 -1
- package/dist/_chunks/{handler-store-bIAstrxQ.js.map → handler-store-aQMpqPe7.js.map} +1 -1
- package/dist/_chunks/{href-validation-BJmD5mZh.js → href-validation-2G-HZaQ8.js} +8 -8
- package/dist/_chunks/href-validation-2G-HZaQ8.js.map +1 -0
- package/dist/_chunks/{navigation-root-B5-RmsPi.js → navigation-root-BQqFQI-n.js} +3 -3
- package/dist/_chunks/{navigation-root-B5-RmsPi.js.map → navigation-root-BQqFQI-n.js.map} +1 -1
- package/dist/_chunks/{registry-BI9cgCt7.js → registry-Dp_hJL4s.js} +1 -1
- package/dist/_chunks/{registry-BI9cgCt7.js.map → registry-Dp_hJL4s.js.map} +1 -1
- package/dist/_chunks/{resolve-schema-DU2nbVOi.js → resolve-schema-D3CbcdCW.js} +2 -2
- package/dist/_chunks/{resolve-schema-DU2nbVOi.js.map → resolve-schema-D3CbcdCW.js.map} +1 -1
- package/dist/_chunks/{router-ref-B2-2kIIC.js → router-ref-B50j42zB.js} +2 -2
- package/dist/_chunks/{router-ref-B2-2kIIC.js.map → router-ref-B50j42zB.js.map} +1 -1
- package/dist/_chunks/{schema-bridge-o0SwFtXW.js → schema-bridge-Cuy8BaYu.js} +2 -2
- package/dist/_chunks/{schema-bridge-o0SwFtXW.js.map → schema-bridge-Cuy8BaYu.js.map} +1 -1
- package/dist/_chunks/{segment-context-Bg0pFbLT.js → segment-context-BnxoPSFQ.js} +1 -1
- package/dist/_chunks/{segment-context-Bg0pFbLT.js.map → segment-context-BnxoPSFQ.js.map} +1 -1
- package/dist/_chunks/{ssr-data-DVX8S7vj.js → ssr-data-DxXyoRrz.js} +1 -1
- package/dist/_chunks/{ssr-data-DVX8S7vj.js.map → ssr-data-DxXyoRrz.js.map} +1 -1
- package/dist/_chunks/{stale-reload-CCqb62Z3.js → stale-reload-DIIIVGh_.js} +1 -1
- package/dist/_chunks/{stale-reload-CCqb62Z3.js.map → stale-reload-DIIIVGh_.js.map} +1 -1
- package/dist/_chunks/{use-query-states-C9G6LP0F.js → use-query-states-D9SBlAfp.js} +2 -2
- package/dist/_chunks/{use-query-states-C9G6LP0F.js.map → use-query-states-D9SBlAfp.js.map} +1 -1
- package/dist/_chunks/{use-segment-params-CIeinIx2.js → use-segment-params-x2poKmk8.js} +2 -2
- package/dist/_chunks/use-segment-params-x2poKmk8.js.map +1 -0
- package/dist/_chunks/{validation-AfEFM5ze.js → validation-BoWM_qI1.js} +31 -2
- package/dist/_chunks/validation-BoWM_qI1.js.map +1 -0
- package/dist/_chunks/walkers-BbEu2_Pp.js.map +1 -1
- package/dist/adapters/cloudflare.js +1 -1
- package/dist/adapters/nitro.js +1 -1
- package/dist/cache/cache-api.d.ts +1 -1
- package/dist/cache/cache-api.d.ts.map +1 -1
- package/dist/cache/index.js +17 -39
- package/dist/cache/index.js.map +1 -1
- package/dist/cache/timber-cache.d.ts +1 -1
- package/dist/cache/timber-cache.d.ts.map +1 -1
- package/dist/client/error-boundary.js +2 -2
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +10 -10
- package/dist/client/index.js.map +1 -1
- package/dist/client/internal.js +8 -8
- package/dist/client/internal.js.map +1 -1
- package/dist/client/link.d.ts.map +1 -1
- package/dist/client/navigation-context.d.ts.map +1 -1
- package/dist/client/use-pathname.d.ts.map +1 -1
- package/dist/client/use-search-params.d.ts.map +1 -1
- package/dist/codec.js +1 -1
- package/dist/cookies/index.js +3 -3
- package/dist/cookies/validation.d.ts +11 -23
- package/dist/cookies/validation.d.ts.map +1 -1
- package/dist/dev-tools/error-page.d.ts.map +1 -1
- package/dist/fonts/virtual-modules.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +106 -3
- package/dist/index.js.map +1 -1
- package/dist/params/define-schema.d.ts.map +1 -1
- package/dist/params/index.js +2 -2
- package/dist/params/index.js.map +1 -1
- package/dist/plugins/cache.d.ts +31 -0
- package/dist/plugins/cache.d.ts.map +1 -0
- package/dist/routing/link-codegen.d.ts.map +1 -1
- package/dist/schema-bridge.d.ts.map +1 -1
- package/dist/search-params/index.js +3 -3
- package/dist/segment-params/index.js +1 -1
- package/dist/server/actions.d.ts.map +1 -1
- package/dist/server/body-limits.d.ts.map +1 -1
- package/dist/server/cookie-context.d.ts +2 -2
- package/dist/server/cookie-context.d.ts.map +1 -1
- package/dist/server/csrf.d.ts +3 -2
- package/dist/server/csrf.d.ts.map +1 -1
- package/dist/server/html-injector-core.d.ts.map +1 -1
- package/dist/server/html-injectors.d.ts.map +1 -1
- package/dist/server/index.js +17 -8
- package/dist/server/index.js.map +1 -1
- package/dist/server/internal.js +50 -13
- package/dist/server/internal.js.map +1 -1
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/primitives.d.ts.map +1 -1
- package/dist/server/route-element-builder.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/revalidate-renderer.d.ts +108 -0
- package/dist/server/rsc-entry/revalidate-renderer.d.ts.map +1 -0
- package/dist/server/rsc-entry/ssr-renderer.d.ts +15 -0
- package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
- package/dist/server/sitemap-handler.d.ts.map +1 -1
- package/dist/server/ssr-wrappers.d.ts.map +1 -1
- package/dist/server/tree-builder.d.ts.map +1 -1
- package/dist/shared/href-validation.d.ts.map +1 -1
- package/package.json +7 -6
- package/src/cache/cache-api.ts +3 -2
- package/src/cache/timber-cache.ts +27 -4
- package/src/cli.ts +0 -0
- package/src/client/index.ts +1 -0
- package/src/client/link.tsx +8 -5
- package/src/client/navigation-context.ts +2 -0
- package/src/client/use-pathname.ts +1 -0
- package/src/client/use-search-params.ts +1 -0
- package/src/client/use-segment-params.ts +1 -0
- package/src/config-validation.ts +1 -1
- package/src/cookies/validation.ts +58 -0
- package/src/dev-tools/error-page.ts +1 -0
- package/src/fonts/virtual-modules.ts +2 -0
- package/src/index.ts +5 -1
- package/src/params/define-schema.ts +1 -0
- package/src/plugins/cache.ts +180 -0
- package/src/routing/link-codegen.ts +5 -3
- package/src/schema-bridge.ts +3 -2
- package/src/server/action-client.ts +31 -13
- package/src/server/actions.ts +20 -3
- package/src/server/body-limits.ts +10 -2
- package/src/server/cookie-context.ts +7 -2
- package/src/server/cookie-parsing.ts +1 -1
- package/src/server/csrf.ts +39 -6
- package/src/server/html-injector-core.ts +6 -5
- package/src/server/html-injectors.ts +8 -5
- package/src/server/pipeline.ts +19 -10
- package/src/server/primitives.ts +11 -1
- package/src/server/route-element-builder.ts +2 -0
- package/src/server/rsc-entry/error-renderer.ts +4 -3
- package/src/server/rsc-entry/index.ts +16 -36
- package/src/server/rsc-entry/revalidate-renderer.ts +344 -0
- package/src/server/rsc-entry/ssr-renderer.ts +76 -0
- package/src/server/sitemap-handler.ts +24 -2
- package/src/server/slot-resolver.ts +8 -2
- package/src/server/ssr-wrappers.tsx +2 -0
- package/src/server/tree-builder.ts +2 -0
- package/src/server/types.ts +2 -2
- package/src/shared/href-validation.ts +12 -8
- package/LICENSE +0 -8
- package/dist/_chunks/actions-B8Fhsosm.js.map +0 -1
- package/dist/_chunks/href-validation-BJmD5mZh.js.map +0 -1
- package/dist/_chunks/use-segment-params-CIeinIx2.js.map +0 -1
- package/dist/_chunks/validation-AfEFM5ze.js.map +0 -1
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* timber-cache-transform — Vite plugin that injects stable callsite IDs
|
|
3
|
+
* into `cache()` calls imported from `@timber-js/app/cache`.
|
|
4
|
+
*
|
|
5
|
+
* Without this plugin, cache keys use a process-local counter (fnIdCounter)
|
|
6
|
+
* which is unstable across cold starts in serverless/multi-instance
|
|
7
|
+
* deployments. With a shared CacheHandler (Redis/KV), colliding counters
|
|
8
|
+
* cause cache key poisoning — serving one route's cached data to another.
|
|
9
|
+
*
|
|
10
|
+
* The plugin transforms:
|
|
11
|
+
* cache(fn, opts) → cache(fn, opts, "stable-id")
|
|
12
|
+
*
|
|
13
|
+
* where stable-id = fnv1a(relativeModulePath + ":cache:" + callIndex).
|
|
14
|
+
*
|
|
15
|
+
* See TIM-997, design/06-caching.md §"Cache Key Security".
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { relative } from 'node:path';
|
|
19
|
+
import MagicString from 'magic-string';
|
|
20
|
+
import { parseAst } from 'vite';
|
|
21
|
+
import type { Plugin } from 'vite';
|
|
22
|
+
import type { PluginContext } from '../plugin-context.js';
|
|
23
|
+
import { fnv1aHash } from '../cache/fast-hash.js';
|
|
24
|
+
|
|
25
|
+
const CACHE_MODULE_SOURCES = new Set(['@timber-js/app/cache', 'timber/cache']);
|
|
26
|
+
|
|
27
|
+
interface PositionedNode {
|
|
28
|
+
type: string;
|
|
29
|
+
start: number;
|
|
30
|
+
end: number;
|
|
31
|
+
[key: string]: unknown;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface IdentifierNode extends PositionedNode {
|
|
35
|
+
type: 'Identifier';
|
|
36
|
+
name: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface ImportDeclarationNode extends PositionedNode {
|
|
40
|
+
type: 'ImportDeclaration';
|
|
41
|
+
source: { value: string };
|
|
42
|
+
specifiers: Array<
|
|
43
|
+
PositionedNode & {
|
|
44
|
+
type: string;
|
|
45
|
+
local: IdentifierNode;
|
|
46
|
+
imported?: IdentifierNode;
|
|
47
|
+
}
|
|
48
|
+
>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface CallExpressionNode extends PositionedNode {
|
|
52
|
+
type: 'CallExpression';
|
|
53
|
+
callee: PositionedNode;
|
|
54
|
+
arguments: PositionedNode[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface ProgramNode extends PositionedNode {
|
|
58
|
+
type: 'Program';
|
|
59
|
+
body: PositionedNode[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Find the local binding name(s) for `cache` imported from the cache module.
|
|
64
|
+
*/
|
|
65
|
+
function findCacheBindings(program: ProgramNode): Set<string> {
|
|
66
|
+
const bindings = new Set<string>();
|
|
67
|
+
for (const stmt of program.body) {
|
|
68
|
+
if (stmt.type !== 'ImportDeclaration') continue;
|
|
69
|
+
const decl = stmt as unknown as ImportDeclarationNode;
|
|
70
|
+
if (!CACHE_MODULE_SOURCES.has(decl.source.value)) continue;
|
|
71
|
+
for (const spec of decl.specifiers) {
|
|
72
|
+
if (spec.type !== 'ImportSpecifier') continue;
|
|
73
|
+
const importedName = spec.imported?.name ?? spec.local.name;
|
|
74
|
+
if (importedName === 'cache') {
|
|
75
|
+
bindings.add(spec.local.name);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return bindings;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Recursively walk AST nodes to find cache() call expressions.
|
|
84
|
+
*
|
|
85
|
+
* cache() is typically called at module scope, but we also handle calls
|
|
86
|
+
* inside functions/blocks for completeness.
|
|
87
|
+
*/
|
|
88
|
+
function findCacheCallSites(
|
|
89
|
+
node: PositionedNode,
|
|
90
|
+
cacheBindings: Set<string>,
|
|
91
|
+
results: CallExpressionNode[]
|
|
92
|
+
): void {
|
|
93
|
+
if (node.type === 'CallExpression') {
|
|
94
|
+
const call = node as unknown as CallExpressionNode;
|
|
95
|
+
if (
|
|
96
|
+
call.callee.type === 'Identifier' &&
|
|
97
|
+
cacheBindings.has((call.callee as IdentifierNode).name) &&
|
|
98
|
+
call.arguments.length === 2
|
|
99
|
+
) {
|
|
100
|
+
results.push(call);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
for (const key of Object.keys(node)) {
|
|
104
|
+
if (key === 'type' || key === 'start' || key === 'end') continue;
|
|
105
|
+
const val = node[key];
|
|
106
|
+
if (val && typeof val === 'object') {
|
|
107
|
+
if (Array.isArray(val)) {
|
|
108
|
+
for (const item of val) {
|
|
109
|
+
if (item && typeof item === 'object' && typeof item.type === 'string') {
|
|
110
|
+
findCacheCallSites(item as PositionedNode, cacheBindings, results);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
} else if (typeof (val as PositionedNode).type === 'string') {
|
|
114
|
+
findCacheCallSites(val as PositionedNode, cacheBindings, results);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Generate a stable callsite ID from a module path and call index.
|
|
122
|
+
*
|
|
123
|
+
* The ID is an FNV-1a hash of the relative module path + call index,
|
|
124
|
+
* providing a deterministic identifier that is:
|
|
125
|
+
* - Stable across cold starts of the same build
|
|
126
|
+
* - Unique across different modules
|
|
127
|
+
* - Unique across multiple cache() calls within the same module
|
|
128
|
+
*/
|
|
129
|
+
function generateStableId(relativeModulePath: string, callIndex: number): string {
|
|
130
|
+
return fnv1aHash(`${relativeModulePath}:cache:${callIndex}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function timberCacheTransform(ctx: PluginContext): Plugin {
|
|
134
|
+
return {
|
|
135
|
+
name: 'timber-cache-transform',
|
|
136
|
+
|
|
137
|
+
transform(code, id) {
|
|
138
|
+
if (!code.includes('@timber-js/app/cache') && !code.includes('timber/cache')) return;
|
|
139
|
+
// Skip node_modules (don't transform third-party code)
|
|
140
|
+
if (id.includes('node_modules')) return;
|
|
141
|
+
|
|
142
|
+
let program: ProgramNode;
|
|
143
|
+
try {
|
|
144
|
+
program = parseAst(code, { lang: 'tsx' }) as unknown as ProgramNode;
|
|
145
|
+
} catch {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const cacheBindings = findCacheBindings(program);
|
|
150
|
+
if (cacheBindings.size === 0) return;
|
|
151
|
+
|
|
152
|
+
const callSites: CallExpressionNode[] = [];
|
|
153
|
+
findCacheCallSites(program, cacheBindings, callSites);
|
|
154
|
+
if (callSites.length === 0) return;
|
|
155
|
+
|
|
156
|
+
// Sort by position in file for deterministic call indices
|
|
157
|
+
callSites.sort((a, b) => a.start - b.start);
|
|
158
|
+
|
|
159
|
+
const root = ctx.root || process.cwd();
|
|
160
|
+
const relPath = relative(root, id);
|
|
161
|
+
const s = new MagicString(code);
|
|
162
|
+
|
|
163
|
+
for (let i = 0; i < callSites.length; i++) {
|
|
164
|
+
const call = callSites[i];
|
|
165
|
+
const stableId = generateStableId(relPath, i);
|
|
166
|
+
// Insert the stable ID as the 3rd argument just before the closing ')'
|
|
167
|
+
const insertPos = call.end - 1;
|
|
168
|
+
s.appendLeft(insertPos, `, "${stableId}"`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
code: s.toString(),
|
|
173
|
+
map: s.generateMap({ hires: 'boundary' }),
|
|
174
|
+
};
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Exported for testing
|
|
180
|
+
export { generateStableId };
|
|
@@ -45,15 +45,15 @@ export function buildResolvedPattern(route: RouteEntry): string | null {
|
|
|
45
45
|
const parts = route.urlPath.split('/');
|
|
46
46
|
const templateParts = parts.map((part) => {
|
|
47
47
|
if (part.startsWith('[[...') && part.endsWith(']]')) {
|
|
48
|
-
//
|
|
48
|
+
// eslint-disable-next-line no-template-curly-in-string -- codegen output
|
|
49
49
|
return '${string}';
|
|
50
50
|
}
|
|
51
51
|
if (part.startsWith('[...') && part.endsWith(']')) {
|
|
52
|
-
//
|
|
52
|
+
// eslint-disable-next-line no-template-curly-in-string -- codegen output
|
|
53
53
|
return '${string}';
|
|
54
54
|
}
|
|
55
55
|
if (part.startsWith('[') && part.endsWith(']')) {
|
|
56
|
-
//
|
|
56
|
+
// eslint-disable-next-line no-template-curly-in-string -- codegen output
|
|
57
57
|
return '${string}';
|
|
58
58
|
}
|
|
59
59
|
return part;
|
|
@@ -118,8 +118,10 @@ export function formatLinkCatchAllOverloads(): string[] {
|
|
|
118
118
|
|
|
119
119
|
// ExternalHref inlined here rather than referenced as an exported
|
|
120
120
|
// alias so the generated .d.ts stands alone without source imports.
|
|
121
|
+
/* eslint-disable no-template-curly-in-string -- codegen output */
|
|
121
122
|
const externalHref =
|
|
122
123
|
'`http://${string}` | `https://${string}` | `mailto:${string}` | `tel:${string}` | `ftp://${string}` | `//${string}` | `#${string}` | `?${string}`';
|
|
124
|
+
/* eslint-enable no-template-curly-in-string */
|
|
123
125
|
|
|
124
126
|
lines.push(' // Typed Link overloads — catch-all (block 2 / emitted second)');
|
|
125
127
|
lines.push(' interface LinkFunction {');
|
package/src/schema-bridge.ts
CHANGED
|
@@ -109,8 +109,9 @@ export function isCodec(value: unknown): value is Codec<unknown> {
|
|
|
109
109
|
export function fromSchema<T>(schema: StandardSchemaV1<T>): Codec<T> {
|
|
110
110
|
return {
|
|
111
111
|
parse(value: string | string[] | undefined): T {
|
|
112
|
-
// For array inputs,
|
|
113
|
-
|
|
112
|
+
// For array inputs (duplicate query keys), use the first value.
|
|
113
|
+
// Browsers and URLSearchParams.get() return the first occurrence.
|
|
114
|
+
const input = Array.isArray(value) ? value[0] : value;
|
|
114
115
|
|
|
115
116
|
// Try parsing the raw value
|
|
116
117
|
const result = validateSync(schema, input);
|
|
@@ -510,26 +510,38 @@ function logValidationFailure(errors: ValidationErrors): void {
|
|
|
510
510
|
* Returns validation errors keyed by field name, or null if all files are ok.
|
|
511
511
|
*/
|
|
512
512
|
function validateFileSizes(input: Record<string, unknown>, limit: number): ValidationErrors | null {
|
|
513
|
-
const errors: ValidationErrors = {};
|
|
514
513
|
const limitKb = Math.round(limit / 1024);
|
|
515
514
|
const limitLabel =
|
|
516
515
|
limit >= 1024 * 1024 ? `${Math.round(limit / (1024 * 1024))}MB` : `${limitKb}KB`;
|
|
517
516
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
)
|
|
517
|
+
const errors: ValidationErrors = {};
|
|
518
|
+
|
|
519
|
+
function walk(obj: Record<string, unknown>, prefix: string): void {
|
|
520
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
521
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
522
|
+
if (value instanceof File && value.size > limit) {
|
|
523
|
+
errors[path] = [
|
|
524
|
+
`File "${value.name}" (${formatSize(value.size)}) exceeds the ${limitLabel} limit`,
|
|
525
|
+
];
|
|
526
|
+
} else if (Array.isArray(value)) {
|
|
527
|
+
for (let i = 0; i < value.length; i++) {
|
|
528
|
+
const item = value[i];
|
|
529
|
+
const itemPath = `${path}[${i}]`;
|
|
530
|
+
if (item instanceof File && item.size > limit) {
|
|
531
|
+
(errors[itemPath] ??= []).push(
|
|
532
|
+
`File "${item.name}" (${formatSize(item.size)}) exceeds the ${limitLabel} limit`
|
|
533
|
+
);
|
|
534
|
+
} else if (typeof item === 'object' && item !== null && !(item instanceof File)) {
|
|
535
|
+
walk(item as Record<string, unknown>, itemPath);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
} else if (typeof value === 'object' && value !== null && !(value instanceof File)) {
|
|
539
|
+
walk(value as Record<string, unknown>, path);
|
|
529
540
|
}
|
|
530
541
|
}
|
|
531
542
|
}
|
|
532
543
|
|
|
544
|
+
walk(input, '');
|
|
533
545
|
return Object.keys(errors).length > 0 ? errors : null;
|
|
534
546
|
}
|
|
535
547
|
|
|
@@ -545,7 +557,13 @@ function stripFiles(value: unknown): Record<string, unknown> | undefined {
|
|
|
545
557
|
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
546
558
|
if (v instanceof File) continue;
|
|
547
559
|
if (Array.isArray(v)) {
|
|
548
|
-
result[k] = v
|
|
560
|
+
result[k] = v
|
|
561
|
+
.filter((item) => !(item instanceof File))
|
|
562
|
+
.map((item) =>
|
|
563
|
+
typeof item === 'object' && item !== null && !(item instanceof File)
|
|
564
|
+
? (stripFiles(item) ?? {})
|
|
565
|
+
: item
|
|
566
|
+
);
|
|
549
567
|
} else if (typeof v === 'object' && v !== null && !(v instanceof File)) {
|
|
550
568
|
result[k] = stripFiles(v) ?? {};
|
|
551
569
|
} else {
|
package/src/server/actions.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { getCacheHandler } from '../cache/handler-store';
|
|
18
|
-
import { RedirectSignal } from './primitives';
|
|
18
|
+
import { DenySignal, RedirectSignal } from './primitives';
|
|
19
19
|
import { withSpan } from './tracing';
|
|
20
20
|
import { revalidationAls, type RevalidationState } from './als-registry.js';
|
|
21
21
|
|
|
@@ -178,11 +178,28 @@ export async function executeAction(
|
|
|
178
178
|
revalidation = await config.renderer(path);
|
|
179
179
|
} catch (renderError) {
|
|
180
180
|
if (renderError instanceof RedirectSignal) {
|
|
181
|
-
// Revalidation triggered a redirect (e.g., session expired
|
|
181
|
+
// Revalidation triggered a redirect (e.g., session expired,
|
|
182
|
+
// middleware on the target path called `redirect()`).
|
|
182
183
|
redirectTo = renderError.location;
|
|
183
184
|
redirectStatus = renderError.status;
|
|
185
|
+
} else if (renderError instanceof DenySignal) {
|
|
186
|
+
// Target path's middleware/access denied this user.
|
|
187
|
+
//
|
|
188
|
+
// Dropping the revalidation here is the security boundary added
|
|
189
|
+
// in TIM-996: without this branch, a DenySignal would fall into
|
|
190
|
+
// the `console.error` path AND the element tree would have
|
|
191
|
+
// already been built and serialized — leaking a privileged RSC
|
|
192
|
+
// payload to the action submitter. By the time the signal
|
|
193
|
+
// surfaces, the renderer has thrown BEFORE building the tree,
|
|
194
|
+
// so we simply drop `revalidation` and ship the action result
|
|
195
|
+
// alone. The action itself still succeeded.
|
|
196
|
+
console.error(
|
|
197
|
+
`[timber] revalidatePath dropped — target middleware denied (status ${renderError.status})`
|
|
198
|
+
);
|
|
184
199
|
} else {
|
|
185
|
-
// Log but don't fail the action — revalidation is best-effort
|
|
200
|
+
// Log but don't fail the action — revalidation is best-effort.
|
|
201
|
+
// Includes input-validation rejections from the renderer
|
|
202
|
+
// (e.g., revalidatePath('//evil.com/admin')).
|
|
186
203
|
console.error('[timber] revalidatePath render failed:', renderError);
|
|
187
204
|
}
|
|
188
205
|
}
|
|
@@ -53,6 +53,9 @@ export function parseBodySize(size: string): number {
|
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/** Strict integer pattern: one or more digits, nothing else. */
|
|
57
|
+
const STRICT_INTEGER_RE = /^\d+$/;
|
|
58
|
+
|
|
56
59
|
/** Check whether a request body exceeds the configured size limit (stateless, no ALS). */
|
|
57
60
|
export function enforceBodyLimits(
|
|
58
61
|
req: Request,
|
|
@@ -66,11 +69,16 @@ export function enforceBodyLimits(
|
|
|
66
69
|
return { ok: false, status: 411 };
|
|
67
70
|
}
|
|
68
71
|
|
|
69
|
-
|
|
70
|
-
|
|
72
|
+
// Reject malformed values: multiple values ("100, 999999"), negative numbers,
|
|
73
|
+
// floats ("100.5"), or non-numeric strings. parseInt would silently parse
|
|
74
|
+
// the first number from "100, 999999" as 100, letting the real body through.
|
|
75
|
+
const trimmed = contentLength.trim();
|
|
76
|
+
if (!STRICT_INTEGER_RE.test(trimmed)) {
|
|
71
77
|
return { ok: false, status: 411 };
|
|
72
78
|
}
|
|
73
79
|
|
|
80
|
+
const bodySize = Number.parseInt(trimmed, 10);
|
|
81
|
+
|
|
74
82
|
const limit = resolveLimit(kind, config);
|
|
75
83
|
return bodySize <= limit ? { ok: true } : { ok: false, status: 413 };
|
|
76
84
|
}
|
|
@@ -14,7 +14,11 @@
|
|
|
14
14
|
|
|
15
15
|
import { requestContextAls, type RequestContextStore } from './als-registry.js';
|
|
16
16
|
import { isDebug } from './debug.js';
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
assertValidCookieName,
|
|
19
|
+
assertValidCookieValue,
|
|
20
|
+
assertValidCookieOptions,
|
|
21
|
+
} from '../cookies/validation.js';
|
|
18
22
|
import {
|
|
19
23
|
parseCookieHeader,
|
|
20
24
|
parseSetCookie,
|
|
@@ -27,7 +31,7 @@ import {
|
|
|
27
31
|
// `getCookies()`. The pure shared module lives in `cookies/validation.ts`
|
|
28
32
|
// so the client `useCookie` hook can use the same checks without pulling
|
|
29
33
|
// in the server ALS code.
|
|
30
|
-
export { assertValidCookieName, assertValidCookieValue };
|
|
34
|
+
export { assertValidCookieName, assertValidCookieValue, assertValidCookieOptions };
|
|
31
35
|
|
|
32
36
|
// ─── Public API ───────────────────────────────────────────────────────────
|
|
33
37
|
|
|
@@ -137,6 +141,7 @@ export function getCookieJar(): RequestCookies {
|
|
|
137
141
|
const { raw: _raw, ...attributeOptions } = options ?? {};
|
|
138
142
|
void _raw;
|
|
139
143
|
const opts = { ...DEFAULT_COOKIE_OPTIONS, ...attributeOptions };
|
|
144
|
+
assertValidCookieOptions(opts);
|
|
140
145
|
store.cookieJar.set(name, { name, value: wireValue, options: opts });
|
|
141
146
|
// Read-your-own-writes: store the DECODED logical value so that
|
|
142
147
|
// subsequent `cookies().get(name)` in the same request returns
|
|
@@ -33,7 +33,7 @@ export function parseCookieHeader(header: string): Map<string, string> {
|
|
|
33
33
|
if (eqIndex === -1) continue;
|
|
34
34
|
const name = pair.slice(0, eqIndex).trim();
|
|
35
35
|
const value = pair.slice(eqIndex + 1).trim();
|
|
36
|
-
if (name) {
|
|
36
|
+
if (name && !map.has(name)) {
|
|
37
37
|
map.set(name, safeDecodeCookieValue(value));
|
|
38
38
|
}
|
|
39
39
|
}
|
package/src/server/csrf.ts
CHANGED
|
@@ -28,12 +28,36 @@ const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
|
|
28
28
|
// ─── Implementation ───────────────────────────────────────────────────────
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
|
-
*
|
|
31
|
+
* Derive the request's scheme from trusted sources.
|
|
32
|
+
*
|
|
33
|
+
* Priority:
|
|
34
|
+
* 1. X-Forwarded-Proto (set by reverse proxies / load balancers)
|
|
35
|
+
* 2. Request URL protocol
|
|
36
|
+
*
|
|
37
|
+
* Falls back to 'https' (fail closed) if neither is available.
|
|
38
|
+
*/
|
|
39
|
+
function deriveRequestScheme(req: Request): string {
|
|
40
|
+
const forwarded = req.headers.get('X-Forwarded-Proto');
|
|
41
|
+
if (forwarded) {
|
|
42
|
+
const proto = forwarded.split(',')[0].trim().toLowerCase();
|
|
43
|
+
if (proto === 'http' || proto === 'https') return proto;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
return new URL(req.url).protocol.replace(':', '');
|
|
48
|
+
} catch {
|
|
49
|
+
return 'https';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Validate the Origin header against the request's full origin
|
|
55
|
+
* (scheme + host + port).
|
|
32
56
|
*
|
|
33
57
|
* For mutation methods (POST, PUT, PATCH, DELETE):
|
|
34
58
|
* - If `csrf: false`, skip validation.
|
|
35
59
|
* - If `allowedOrigins` is set, Origin must match one exactly (no wildcards).
|
|
36
|
-
* - Otherwise, Origin
|
|
60
|
+
* - Otherwise, Origin must match the derived request origin.
|
|
37
61
|
*
|
|
38
62
|
* Safe methods (GET, HEAD, OPTIONS) always pass.
|
|
39
63
|
*/
|
|
@@ -67,13 +91,22 @@ export function validateCsrf(req: Request, config: CsrfConfig): CsrfResult {
|
|
|
67
91
|
return { ok: false, status: 403 };
|
|
68
92
|
}
|
|
69
93
|
|
|
70
|
-
//
|
|
71
|
-
|
|
94
|
+
// Compare full origins (scheme + host + port) using URL.origin for
|
|
95
|
+
// canonical port normalization (e.g. https://x:443 → https://x).
|
|
96
|
+
let originOrigin: string;
|
|
97
|
+
try {
|
|
98
|
+
originOrigin = new URL(origin).origin;
|
|
99
|
+
} catch {
|
|
100
|
+
return { ok: false, status: 403 };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const scheme = deriveRequestScheme(req);
|
|
104
|
+
let expectedOrigin: string;
|
|
72
105
|
try {
|
|
73
|
-
|
|
106
|
+
expectedOrigin = new URL(`${scheme}://${host}`).origin;
|
|
74
107
|
} catch {
|
|
75
108
|
return { ok: false, status: 403 };
|
|
76
109
|
}
|
|
77
110
|
|
|
78
|
-
return
|
|
111
|
+
return originOrigin === expectedOrigin ? { ok: true } : { ok: false, status: 403 };
|
|
79
112
|
}
|
|
@@ -309,7 +309,7 @@ export class FlightInjectorCore {
|
|
|
309
309
|
errorSignal?: AbortSignal
|
|
310
310
|
) {
|
|
311
311
|
this.rscReader = rscStream.getReader();
|
|
312
|
-
this.timeoutMs = renderTimeoutMs ?? 30_000;
|
|
312
|
+
this.timeoutMs = (renderTimeoutMs ?? 30_000) || 120_000;
|
|
313
313
|
this.errorSignal = errorSignal;
|
|
314
314
|
this.machine = createMachine<FlightInjectionState, FlightInjectionEvent>({
|
|
315
315
|
initial: { phase: 'init' },
|
|
@@ -373,10 +373,11 @@ export class FlightInjectorCore {
|
|
|
373
373
|
// Guard each read with a timeout so a permanently hung RSC stream
|
|
374
374
|
// eventually aborts. See design/02 §"Streaming Constraints".
|
|
375
375
|
const readPromise = this.rscReader.read();
|
|
376
|
-
const { done, value } =
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
376
|
+
const { done, value } = await withTimeout(
|
|
377
|
+
readPromise,
|
|
378
|
+
this.timeoutMs,
|
|
379
|
+
'RSC stream read timed out'
|
|
380
|
+
);
|
|
380
381
|
|
|
381
382
|
if (done) {
|
|
382
383
|
this.machine.send({ type: 'PULL_DONE' });
|
|
@@ -201,16 +201,19 @@ export function createInlinedRscStream(
|
|
|
201
201
|
): ReadableStream<Uint8Array> {
|
|
202
202
|
const rscReader = rscStream.getReader();
|
|
203
203
|
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
204
|
-
|
|
204
|
+
// When renderTimeoutMs is 0 ("disabled"), enforce a safety ceiling
|
|
205
|
+
// so a stalled RSC stream can't hang the connection forever.
|
|
206
|
+
const timeoutMs = (renderTimeoutMs ?? 30_000) || 120_000;
|
|
205
207
|
|
|
206
208
|
return new ReadableStream<Uint8Array>({
|
|
207
209
|
async pull(controller) {
|
|
208
210
|
try {
|
|
209
211
|
const readPromise = rscReader.read();
|
|
210
|
-
const { done, value } =
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
212
|
+
const { done, value } = await withTimeout(
|
|
213
|
+
readPromise,
|
|
214
|
+
timeoutMs,
|
|
215
|
+
'RSC stream read timed out'
|
|
216
|
+
);
|
|
214
217
|
if (done) {
|
|
215
218
|
controller.close();
|
|
216
219
|
return;
|
package/src/server/pipeline.ts
CHANGED
|
@@ -338,17 +338,26 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
338
338
|
// separately for the same URL. Without this, a shared cache may
|
|
339
339
|
// serve an RSC Flight payload to a browser expecting HTML (or
|
|
340
340
|
// vice versa). See GHSA-wfc6-r584-vfw7, design/13-security.md.
|
|
341
|
+
//
|
|
342
|
+
// Vary: X-Timber-URL — intercepting route responses depend on
|
|
343
|
+
// the source URL, so caches must store them separately.
|
|
344
|
+
const varyTokens = ['Accept'];
|
|
345
|
+
if (config.interceptionRewrites?.length) {
|
|
346
|
+
varyTokens.push('X-Timber-URL');
|
|
347
|
+
}
|
|
341
348
|
const existingVary = result.headers.get('Vary');
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
349
|
+
const existingTokens = existingVary
|
|
350
|
+
? existingVary
|
|
351
|
+
.toLowerCase()
|
|
352
|
+
.split(',')
|
|
353
|
+
.map((t) => t.trim())
|
|
354
|
+
: [];
|
|
355
|
+
const newTokens = varyTokens.filter((t) => !existingTokens.includes(t.toLowerCase()));
|
|
356
|
+
if (newTokens.length > 0) {
|
|
357
|
+
result.headers.set(
|
|
358
|
+
'Vary',
|
|
359
|
+
existingVary ? `${existingVary}, ${newTokens.join(', ')}` : newTokens.join(', ')
|
|
360
|
+
);
|
|
352
361
|
}
|
|
353
362
|
|
|
354
363
|
// Append Server-Timing header based on configured mode.
|
package/src/server/primitives.ts
CHANGED
|
@@ -240,6 +240,9 @@ export class RedirectSignal extends Error {
|
|
|
240
240
|
/** Pattern matching absolute URLs: http(s):// or protocol-relative // */
|
|
241
241
|
const ABSOLUTE_URL_RE = /^(?:[a-zA-Z][a-zA-Z\d+\-.]*:|\/\/)/;
|
|
242
242
|
|
|
243
|
+
// eslint-disable-next-line no-control-regex -- must match all C0 controls to prevent bypass
|
|
244
|
+
const REDIRECT_CONTROL_CHARS_RE = /[\x00-\x1f]/g;
|
|
245
|
+
|
|
243
246
|
/**
|
|
244
247
|
* Options for redirect() — alternative to passing a bare status code.
|
|
245
248
|
*/
|
|
@@ -300,7 +303,14 @@ export function redirect(path: string, statusOrOptions?: number | RedirectOption
|
|
|
300
303
|
if (status < 300 || status > 399) {
|
|
301
304
|
throw new Error(`redirect() requires a 3xx status code, got ${status}.`);
|
|
302
305
|
}
|
|
303
|
-
|
|
306
|
+
// Strip C0 control characters and trim whitespace before checking — browsers
|
|
307
|
+
// do this during URL resolution, so " \thttp://evil.com" resolves as absolute.
|
|
308
|
+
// Normalize for validation only: strip C0 controls, trim, then replace backslashes
|
|
309
|
+
// with forward slashes. Browsers normalize \ → / during URL resolution, so
|
|
310
|
+
// "/\evil.com" becomes "//evil.com" (protocol-relative open redirect).
|
|
311
|
+
const cleaned = path.replace(REDIRECT_CONTROL_CHARS_RE, '').trimStart().replaceAll('\\', '/');
|
|
312
|
+
|
|
313
|
+
if (ABSOLUTE_URL_RE.test(cleaned)) {
|
|
304
314
|
throw new Error(
|
|
305
315
|
`redirect() only accepts relative URLs. Got absolute URL: "${path}". ` +
|
|
306
316
|
'Use redirectExternal(url, allowList) for external redirects.'
|
|
@@ -67,10 +67,12 @@ function replaceOutermostSegmentOutlet(
|
|
|
67
67
|
const props = element.props as Record<string, unknown>;
|
|
68
68
|
if (element.type === AccessGate && props.children) {
|
|
69
69
|
const inner = replaceOutermostSegmentOutlet(props.children as React.ReactElement, replacement);
|
|
70
|
+
/* eslint-disable react/no-children-prop -- createElement API */
|
|
70
71
|
return createElement(element.type as React.FunctionComponent<Record<string, unknown>>, {
|
|
71
72
|
...props,
|
|
72
73
|
children: inner,
|
|
73
74
|
});
|
|
75
|
+
/* eslint-enable react/no-children-prop */
|
|
74
76
|
}
|
|
75
77
|
return element;
|
|
76
78
|
}
|
|
@@ -152,10 +152,11 @@ function getLayoutsForErrorFile(
|
|
|
152
152
|
*/
|
|
153
153
|
function toSerializableError(error: unknown): SerializableError {
|
|
154
154
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
155
|
+
const devMode = isDevMode();
|
|
155
156
|
return {
|
|
156
|
-
message: err.message,
|
|
157
|
-
name: err.name,
|
|
158
|
-
...(
|
|
157
|
+
message: devMode ? err.message : 'An unexpected error occurred.',
|
|
158
|
+
name: devMode ? err.name : 'Error',
|
|
159
|
+
...(devMode && err.stack ? { stack: err.stack } : {}),
|
|
159
160
|
};
|
|
160
161
|
}
|
|
161
162
|
|