@timber-js/app 0.2.0-alpha.133 → 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-BNhpxrnV.js → actions-CHFblsdT.js} +62 -19
- 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/cli-schema-sync-q-Ce6-iG.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-2G-HZaQ8.js +124 -0
- package/dist/_chunks/href-validation-2G-HZaQ8.js.map +1 -0
- package/dist/_chunks/logger-CMd9P1xQ.js.map +1 -1
- package/dist/_chunks/metadata-routes-DESG8uk5.js.map +1 -1
- 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-classify-BjfuctV2.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/tracing-DP5djRq5.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/_chunks/warnings-CAzunaGo.js.map +1 -1
- package/dist/adapters/cloudflare-dev.js.map +1 -1
- package/dist/adapters/cloudflare-kv-cache.js.map +1 -1
- package/dist/adapters/cloudflare.js +1 -1
- package/dist/adapters/cloudflare.js.map +1 -1
- package/dist/adapters/nitro.js +1 -1
- package/dist/adapters/nitro.js.map +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/cli.js.map +1 -1
- package/dist/client/error-boundary.js +2 -2
- package/dist/client/error-boundary.js.map +1 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +14 -26
- 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 +2 -4
- 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-router.d.ts.map +1 -1
- package/dist/client/use-search-params.d.ts.map +1 -1
- package/dist/codec.js +1 -1
- package/dist/codec.js.map +1 -1
- package/dist/cookies/index.js +3 -3
- package/dist/cookies/index.js.map +1 -1
- 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 +259 -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/search-params/index.js.map +1 -1
- 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/deny-renderer.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 +69 -21
- package/dist/server/internal.js.map +1 -1
- package/dist/server/pipeline-interception.d.ts.map +1 -1
- package/dist/server/pipeline-phases.d.ts +5 -1
- package/dist/server/pipeline-phases.d.ts.map +1 -1
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/primitives.d.ts +5 -2
- 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/rsc-payload.d.ts.map +1 -1
- 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 +38 -0
- package/dist/shared/href-validation.d.ts.map +1 -0
- package/dist/shims/font-google.js.map +1 -1
- package/dist/shims/font-local.js.map +1 -1
- package/package.json +5 -5
- package/src/cache/cache-api.ts +3 -2
- package/src/cache/timber-cache.ts +27 -4
- package/src/client/index.ts +1 -0
- package/src/client/link.tsx +13 -37
- package/src/client/navigation-context.ts +2 -0
- package/src/client/use-pathname.ts +1 -0
- package/src/client/use-router.ts +3 -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/deny-renderer.ts +0 -1
- package/src/server/html-injector-core.ts +6 -5
- package/src/server/html-injectors.ts +8 -5
- package/src/server/pipeline-interception.ts +1 -0
- package/src/server/pipeline-phases.ts +30 -19
- package/src/server/pipeline.ts +27 -1
- package/src/server/primitives.ts +19 -18
- 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/rsc-payload.ts +0 -4
- 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 +127 -0
- package/dist/_chunks/actions-BNhpxrnV.js.map +0 -1
- package/dist/_chunks/merge-search-params-Cm1j99HC.js +0 -41
- package/dist/_chunks/merge-search-params-Cm1j99HC.js.map +0 -1
- package/dist/_chunks/use-segment-params-CIeinIx2.js.map +0 -1
- package/dist/_chunks/validation-AfEFM5ze.js.map +0 -1
|
@@ -104,6 +104,64 @@ function isCookieOctet(code: number): boolean {
|
|
|
104
104
|
* This validator is for the `{ raw: true }` opt-out and for
|
|
105
105
|
* `setFromHeaders` forwarding.
|
|
106
106
|
*/
|
|
107
|
+
// ─── Cookie attribute validation ──────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Characters that must not appear in path or domain cookie attributes.
|
|
111
|
+
* Semicolons delimit cookie attributes on the wire, so allowing `;` in
|
|
112
|
+
* path or domain would inject extra attributes. CR/LF enable HTTP header
|
|
113
|
+
* injection.
|
|
114
|
+
*/
|
|
115
|
+
const ATTR_INJECTION_RE = /[;\r\n]/;
|
|
116
|
+
|
|
117
|
+
const VALID_SAME_SITE = new Set(['strict', 'lax', 'none']);
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Validate cookie option attributes at runtime to prevent Set-Cookie
|
|
121
|
+
* header injection through unsanitized option values.
|
|
122
|
+
*
|
|
123
|
+
* TypeScript types enforce correct shapes at compile time, but at runtime
|
|
124
|
+
* (e.g. from untyped libraries or user-controlled config) invalid values
|
|
125
|
+
* could inject additional Set-Cookie attributes.
|
|
126
|
+
*/
|
|
127
|
+
export function assertValidCookieOptions(options: {
|
|
128
|
+
sameSite?: string;
|
|
129
|
+
path?: string;
|
|
130
|
+
domain?: string;
|
|
131
|
+
maxAge?: number;
|
|
132
|
+
}): void {
|
|
133
|
+
if (options.sameSite !== undefined) {
|
|
134
|
+
const lower = options.sameSite.toLowerCase();
|
|
135
|
+
if (!VALID_SAME_SITE.has(lower)) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
`[timber] invalid cookie sameSite value ${JSON.stringify(options.sameSite)} — ` +
|
|
138
|
+
`must be 'strict', 'lax', or 'none'.`
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (options.path !== undefined && ATTR_INJECTION_RE.test(options.path)) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
`[timber] invalid cookie path ${JSON.stringify(options.path)} — ` +
|
|
145
|
+
`path must not contain ';', CR, or LF (Set-Cookie header injection).`
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
if (options.domain !== undefined && ATTR_INJECTION_RE.test(options.domain)) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
`[timber] invalid cookie domain ${JSON.stringify(options.domain)} — ` +
|
|
151
|
+
`domain must not contain ';', CR, or LF (Set-Cookie header injection).`
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
if (
|
|
155
|
+
options.maxAge !== undefined &&
|
|
156
|
+
(typeof options.maxAge !== 'number' || !Number.isFinite(options.maxAge))
|
|
157
|
+
) {
|
|
158
|
+
throw new Error(
|
|
159
|
+
`[timber] invalid cookie maxAge ${JSON.stringify(options.maxAge)} — ` +
|
|
160
|
+
`maxAge must be a finite number.`
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
107
165
|
export function assertValidCookieValue(name: string, value: string): void {
|
|
108
166
|
if (typeof value !== 'string') {
|
|
109
167
|
throw new Error(`[timber] cookie ${JSON.stringify(name)}: cookie value must be a string.`);
|
|
@@ -53,6 +53,7 @@ function readSourceContext(
|
|
|
53
53
|
contextLines = 3
|
|
54
54
|
): { lines: Array<{ num: number; text: string; highlight: boolean }>; startLine: number } | null {
|
|
55
55
|
try {
|
|
56
|
+
// eslint-disable-next-line no-var-requires -- sync require needed at render time
|
|
56
57
|
const { readFileSync } = require('node:fs');
|
|
57
58
|
const content = readFileSync(filePath, 'utf-8');
|
|
58
59
|
const allLines = content.split('\n');
|
|
@@ -131,6 +131,7 @@ export function generateGoogleVirtualModule(fonts: Iterable<ExtractedFont>): str
|
|
|
131
131
|
'',
|
|
132
132
|
'function createFontResult(family, config) {',
|
|
133
133
|
' return {',
|
|
134
|
+
// eslint-disable-next-line no-template-curly-in-string -- codegen output
|
|
134
135
|
' className: `timber-font-${family.toLowerCase().replace(/\\s+/g, "-")}`,',
|
|
135
136
|
' style: { fontFamily: family },',
|
|
136
137
|
' variable: config?.variable,',
|
|
@@ -171,6 +172,7 @@ export function generateLocalVirtualModule(): string {
|
|
|
171
172
|
'export default function localFont(config) {',
|
|
172
173
|
' const family = config?.family || "Local Font";',
|
|
173
174
|
' return {',
|
|
175
|
+
// eslint-disable-next-line no-template-curly-in-string -- codegen output
|
|
174
176
|
' className: `timber-font-${family.toLowerCase().replace(/\\s+/g, "-")}`,',
|
|
175
177
|
' style: { fontFamily: family },',
|
|
176
178
|
' variable: config?.variable,',
|
package/src/index.ts
CHANGED
|
@@ -29,6 +29,7 @@ import { timberReactProd } from './plugins/react-prod';
|
|
|
29
29
|
import { timberChunks } from './plugins/chunks';
|
|
30
30
|
import { clientChunkGroup } from './plugins/client-chunks';
|
|
31
31
|
import { timberServerBundle } from './plugins/server-bundle';
|
|
32
|
+
import { timberCacheTransform } from './plugins/cache';
|
|
32
33
|
import { timberCloudflareWasm } from './plugins/cloudflare-wasm';
|
|
33
34
|
import { timberAdapterBuild } from './plugins/adapter-build';
|
|
34
35
|
import { timberBuildReport } from './plugins/build-report';
|
|
@@ -70,7 +71,7 @@ export type { Metadata, MetadataRoute, MetadataHandler, MetadataResult } from '.
|
|
|
70
71
|
* This interface is empty by default and populated via codegen.
|
|
71
72
|
* See design/09-typescript.md §"Typed Routes".
|
|
72
73
|
*/
|
|
73
|
-
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
74
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type, typescript/no-empty-interface
|
|
74
75
|
export interface Routes {}
|
|
75
76
|
|
|
76
77
|
/**
|
|
@@ -464,8 +465,10 @@ export function timber(config?: TimberUserConfig): PluginOption[] {
|
|
|
464
465
|
// Validate config and check peer dependencies at startup.
|
|
465
466
|
// Errors are logged to stderr but don't block startup — Vite may
|
|
466
467
|
// still work partially, and the errors guide the user to fix things.
|
|
468
|
+
/* eslint-disable no-var-requires -- sync require for startup validation */
|
|
467
469
|
const { validateConfig, formatConfigErrors, checkPeerDependencies, formatMissingPeers } =
|
|
468
470
|
require('./config-validation.js') as typeof import('./config-validation.js');
|
|
471
|
+
/* eslint-enable no-var-requires */
|
|
469
472
|
|
|
470
473
|
const configErrors = validateConfig(ctx.config);
|
|
471
474
|
if (configErrors.length > 0) {
|
|
@@ -530,6 +533,7 @@ export function timber(config?: TimberUserConfig): PluginOption[] {
|
|
|
530
533
|
timberStaticBuild(ctx),
|
|
531
534
|
timberFonts(ctx),
|
|
532
535
|
timberMdx(ctx),
|
|
536
|
+
timberCacheTransform(ctx),
|
|
533
537
|
timberContent(ctx),
|
|
534
538
|
timberServerBundle(), // Bundle all deps in server environments for prod
|
|
535
539
|
timberCloudflareWasm(ctx), // Externalize WASM for Cloudflare Workers
|
|
@@ -57,6 +57,7 @@ export type CodecForKey<K extends string> = K extends `[[...${string}]]`
|
|
|
57
57
|
* Registry interface augmented by codegen with known filesystem bracket keys.
|
|
58
58
|
* When empty (no codegen yet), ValidDynamicSegmentKeys falls back to string.
|
|
59
59
|
*/
|
|
60
|
+
// eslint-disable-next-line typescript/no-empty-interface -- augmented by codegen
|
|
60
61
|
export interface ValidDynamicSegmentKeysRegistry {}
|
|
61
62
|
|
|
62
63
|
/**
|
|
@@ -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
|
}
|
|
@@ -228,7 +228,6 @@ export async function renderDenyPageAsRsc(
|
|
|
228
228
|
});
|
|
229
229
|
|
|
230
230
|
responseHeaders.set('content-type', `${RSC_CONTENT_TYPE}; charset=utf-8`);
|
|
231
|
-
responseHeaders.set('Vary', 'Accept');
|
|
232
231
|
return new Response(rscStream, {
|
|
233
232
|
status: deny.status,
|
|
234
233
|
headers: responseHeaders,
|
|
@@ -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;
|