@timber-js/app 0.2.0-alpha.110 → 0.2.0-alpha.112
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-D6gyxlGQ.js → actions-ILhDUzES.js} +46 -10
- package/dist/_chunks/actions-ILhDUzES.js.map +1 -0
- package/dist/_chunks/{build-output-helper-5TK2Emwp.js → build-output-helper-DhxiT18k.js} +1 -1
- package/dist/_chunks/{build-output-helper-5TK2Emwp.js.map → build-output-helper-DhxiT18k.js.map} +1 -1
- package/dist/_chunks/cli-schema-sync-BhXdbmgS.js +2 -0
- package/dist/_chunks/cli-schema-sync-DFZmnmqG.js +357 -0
- package/dist/_chunks/cli-schema-sync-DFZmnmqG.js.map +1 -0
- package/dist/_chunks/codec-registry-V7ARyeLN.js +21 -0
- package/dist/_chunks/codec-registry-V7ARyeLN.js.map +1 -0
- package/dist/_chunks/{define-Dn98bPbb.js → define-8zQ8Vm7x.js} +3 -3
- package/dist/_chunks/{define-Dn98bPbb.js.map → define-8zQ8Vm7x.js.map} +1 -1
- package/dist/_chunks/{define-BflNsrwD.js → define-a0CCE6Nx.js} +3 -3
- package/dist/_chunks/{define-BflNsrwD.js.map → define-a0CCE6Nx.js.map} +1 -1
- package/dist/_chunks/{format-CYBGxKtc.js → format-CR6v1pWE.js} +1 -1
- package/dist/_chunks/{format-CYBGxKtc.js.map → format-CR6v1pWE.js.map} +1 -1
- package/dist/_chunks/{handler-store-B_df5Npe.js → handler-store-iqnt2ghN.js} +1 -1
- package/dist/_chunks/{handler-store-B_df5Npe.js.map → handler-store-iqnt2ghN.js.map} +1 -1
- package/dist/_chunks/{merge-search-params-Cm_KIWDX.js → merge-search-params-CwxJzBLS.js} +1 -1
- package/dist/_chunks/{merge-search-params-Cm_KIWDX.js.map → merge-search-params-CwxJzBLS.js.map} +1 -1
- package/dist/_chunks/{navigation-root-2ypaZQDx.js → navigation-root-CY3pRMxF.js} +2 -2
- package/dist/_chunks/{navigation-root-2ypaZQDx.js.map → navigation-root-CY3pRMxF.js.map} +1 -1
- package/dist/_chunks/{registry-DB93kT5c.js → registry-Dp_hJL4s.js} +1 -1
- package/dist/_chunks/{registry-DB93kT5c.js.map → registry-Dp_hJL4s.js.map} +1 -1
- package/dist/_chunks/resolve-schema-DO8B0mO9.js +65 -0
- package/dist/_chunks/resolve-schema-DO8B0mO9.js.map +1 -0
- package/dist/_chunks/{router-ref-TkUdmqtM.js → router-ref-sektkr-t.js} +2 -2
- package/dist/_chunks/{router-ref-TkUdmqtM.js.map → router-ref-sektkr-t.js.map} +1 -1
- package/dist/_chunks/scanner-DmfoB7iK.js +336 -0
- package/dist/_chunks/scanner-DmfoB7iK.js.map +1 -0
- package/dist/_chunks/{schema-bridge-C3xl_vfb.js → schema-bridge-eL5vQDSA.js} +1 -1
- package/dist/_chunks/{schema-bridge-C3xl_vfb.js.map → schema-bridge-eL5vQDSA.js.map} +1 -1
- package/dist/_chunks/{segment-context-Dc-GbSTe.js → segment-context-EsfQpS--.js} +1 -1
- package/dist/_chunks/{segment-context-Dc-GbSTe.js.map → segment-context-EsfQpS--.js.map} +1 -1
- package/dist/_chunks/{ssr-data-DzuI0bIV.js → ssr-data-BO5OvEvg.js} +1 -1
- package/dist/_chunks/{ssr-data-DzuI0bIV.js.map → ssr-data-BO5OvEvg.js.map} +1 -1
- package/dist/_chunks/{stale-reload-D6FlD3xn.js → stale-reload-BCPRG0Cv.js} +1 -1
- package/dist/_chunks/{stale-reload-D6FlD3xn.js.map → stale-reload-BCPRG0Cv.js.map} +1 -1
- package/dist/_chunks/tracing-DvKLzCFa.js.map +1 -1
- package/dist/_chunks/{use-query-states-B-cLl8mx.js → use-query-states-D9SBlAfp.js} +2 -2
- package/dist/_chunks/{use-query-states-B-cLl8mx.js.map → use-query-states-D9SBlAfp.js.map} +1 -1
- package/dist/_chunks/{use-segment-params-07V1TIxL.js → use-segment-params-BSt_O2cb.js} +2 -2
- package/dist/_chunks/{use-segment-params-07V1TIxL.js.map → use-segment-params-BSt_O2cb.js.map} +1 -1
- package/dist/_chunks/{validation-DfyuMoK2.js → validation-Bm3Je-09.js} +1 -1
- package/dist/_chunks/{validation-DfyuMoK2.js.map → validation-Bm3Je-09.js.map} +1 -1
- package/dist/_chunks/{walkers-DQn5NNrf.js → walkers-DGTmJT0f.js} +109 -353
- package/dist/_chunks/walkers-DGTmJT0f.js.map +1 -0
- package/dist/adapters/cloudflare.js +1 -1
- package/dist/adapters/nitro.js +1 -1
- package/dist/cache/index.js +3 -2
- package/dist/cache/index.js.map +1 -1
- package/dist/cache/sizeof.d.ts.map +1 -1
- package/dist/cli-schema-sync.d.ts +37 -0
- package/dist/cli-schema-sync.d.ts.map +1 -0
- package/dist/cli.d.ts +8 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +34 -3
- package/dist/cli.js.map +1 -1
- package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
- package/dist/client/error-boundary.js +5 -3
- package/dist/client/error-boundary.js.map +1 -1
- package/dist/client/index.js +27 -9
- package/dist/client/index.js.map +1 -1
- package/dist/client/internal.js +7 -7
- package/dist/client/link.d.ts.map +1 -1
- package/dist/codec.js +1 -1
- package/dist/cookies/index.js +3 -3
- package/dist/dev-tools/browser-logs.d.ts.map +1 -1
- package/dist/dev-tools/error-page.d.ts.map +1 -1
- package/dist/dev-tools/logs.d.ts.map +1 -1
- package/dist/dev-tools/overlay.d.ts +1 -1
- package/dist/dev-tools/overlay.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +98 -236
- package/dist/index.js.map +1 -1
- package/dist/params/codec-registry.d.ts +26 -0
- package/dist/params/codec-registry.d.ts.map +1 -0
- package/dist/params/define-schema.d.ts +52 -0
- package/dist/params/define-schema.d.ts.map +1 -0
- package/dist/params/index.d.ts +5 -0
- package/dist/params/index.d.ts.map +1 -0
- package/dist/params/index.js +33 -0
- package/dist/params/index.js.map +1 -0
- package/dist/params/resolve-schema.d.ts +34 -0
- package/dist/params/resolve-schema.d.ts.map +1 -0
- package/dist/plugins/mdx.d.ts.map +1 -1
- package/dist/plugins/routing.d.ts.map +1 -1
- package/dist/routing/codegen-shared.d.ts +8 -0
- package/dist/routing/codegen-shared.d.ts.map +1 -1
- package/dist/routing/codegen-types.d.ts +5 -0
- package/dist/routing/codegen-types.d.ts.map +1 -1
- package/dist/routing/codegen.d.ts +5 -0
- package/dist/routing/codegen.d.ts.map +1 -1
- package/dist/routing/index.js +2 -1
- package/dist/routing/link-codegen.d.ts +2 -2
- package/dist/routing/link-codegen.d.ts.map +1 -1
- package/dist/search-params/index.js +3 -3
- package/dist/segment-params/index.js +1 -1
- package/dist/server/als-registry.d.ts +7 -0
- package/dist/server/als-registry.d.ts.map +1 -1
- package/dist/server/coalesce-tick.d.ts +40 -0
- package/dist/server/coalesce-tick.d.ts.map +1 -0
- package/dist/server/fallback-error.d.ts.map +1 -1
- package/dist/server/html-injectors.d.ts.map +1 -1
- package/dist/server/index.js +2 -2
- package/dist/server/internal.js +30 -4
- package/dist/server/internal.js.map +1 -1
- package/dist/server/node-stream-transforms.d.ts.map +1 -1
- package/dist/server/param-coercion.d.ts +12 -2
- package/dist/server/param-coercion.d.ts.map +1 -1
- package/dist/server/pipeline-phases.d.ts.map +1 -1
- package/dist/server/pipeline.d.ts +18 -0
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/request-context.d.ts +17 -3
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts +8 -14
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/package.json +11 -6
- package/src/cache/sizeof.ts +3 -1
- package/src/cli-schema-sync.ts +206 -0
- package/src/cli.ts +49 -2
- package/src/client/browser-entry/action-dispatch.ts +15 -5
- package/src/client/browser-entry/index.ts +6 -0
- package/src/client/error-boundary.tsx +2 -2
- package/src/client/link.tsx +25 -1
- package/src/dev-tools/browser-logs.ts +5 -2
- package/src/dev-tools/error-page.ts +3 -6
- package/src/dev-tools/logs.ts +6 -2
- package/src/dev-tools/overlay.ts +18 -5
- package/src/index.ts +5 -0
- package/src/params/codec-registry.ts +34 -0
- package/src/params/define-schema.ts +72 -0
- package/src/params/index.ts +11 -0
- package/src/params/resolve-schema.ts +88 -0
- package/src/plugins/dev-server.ts +24 -16
- package/src/plugins/mdx.ts +5 -1
- package/src/plugins/routing.ts +107 -2
- package/src/routing/codegen-shared.ts +11 -0
- package/src/routing/codegen-types.ts +5 -0
- package/src/routing/codegen.ts +129 -21
- package/src/routing/link-codegen.ts +19 -5
- package/src/server/als-registry.ts +7 -0
- package/src/server/coalesce-tick.ts +75 -0
- package/src/server/fallback-error.ts +3 -3
- package/src/server/html-injectors.ts +11 -22
- package/src/server/node-stream-transforms.ts +6 -16
- package/src/server/param-coercion.ts +55 -2
- package/src/server/pipeline-phases.ts +8 -0
- package/src/server/pipeline.ts +19 -0
- package/src/server/request-context.ts +58 -3
- package/src/server/rsc-entry/helpers.ts +3 -2
- package/src/server/rsc-entry/index.ts +28 -35
- package/LICENSE +0 -8
- package/dist/_chunks/actions-D6gyxlGQ.js.map +0 -1
- package/dist/_chunks/walkers-DQn5NNrf.js.map +0 -1
package/src/routing/codegen.ts
CHANGED
|
@@ -9,10 +9,12 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { existsSync, readFileSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
12
13
|
import type { RouteTree, SegmentNode } from './types.js';
|
|
13
14
|
import type { ParamEntry, RouteEntry } from './codegen-types.js';
|
|
14
15
|
import {
|
|
15
16
|
buildCodecChainType,
|
|
17
|
+
buildSchemaParamType,
|
|
16
18
|
emitResolveSegmentFieldHelper,
|
|
17
19
|
formatSearchParamsType,
|
|
18
20
|
} from './codegen-shared.js';
|
|
@@ -28,6 +30,11 @@ export interface CodegenOptions {
|
|
|
28
30
|
* Defaults to appDir when not provided (preserves backward compat for tests).
|
|
29
31
|
*/
|
|
30
32
|
outputDir?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Whether a global schema (app/schema.ts) exists.
|
|
35
|
+
* Auto-detected from appDir when not explicitly set.
|
|
36
|
+
*/
|
|
37
|
+
hasSchema?: boolean;
|
|
31
38
|
}
|
|
32
39
|
|
|
33
40
|
/**
|
|
@@ -37,8 +44,11 @@ export interface CodegenOptions {
|
|
|
37
44
|
* interface that maps every route path to its params and searchParams shape.
|
|
38
45
|
*/
|
|
39
46
|
export function generateRouteMap(tree: RouteTree, options: CodegenOptions = {}): string {
|
|
47
|
+
// Auto-detect schema from appDir when not explicitly set
|
|
48
|
+
const hasSchema = options.hasSchema ?? detectSchema(options.appDir);
|
|
49
|
+
|
|
40
50
|
const routes: RouteEntry[] = [];
|
|
41
|
-
collectRoutes(tree.root, [], [], routes);
|
|
51
|
+
collectRoutes(tree.root, [], [], routes, hasSchema);
|
|
42
52
|
|
|
43
53
|
// Sort routes alphabetically for deterministic output
|
|
44
54
|
routes.sort((a, b) => a.urlPath.localeCompare(b.urlPath));
|
|
@@ -46,7 +56,18 @@ export function generateRouteMap(tree: RouteTree, options: CodegenOptions = {}):
|
|
|
46
56
|
// When outputDir differs from appDir, import paths must be relative to outputDir
|
|
47
57
|
const importBase = options.outputDir ?? options.appDir;
|
|
48
58
|
|
|
49
|
-
return formatDeclarationFile(routes, importBase);
|
|
59
|
+
return formatDeclarationFile(routes, importBase, hasSchema);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Schema file extensions to check, in priority order. */
|
|
63
|
+
const SCHEMA_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx'];
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Detect whether app/schema.{ts,tsx,js,jsx} exists.
|
|
67
|
+
*/
|
|
68
|
+
function detectSchema(appDir: string | undefined): boolean {
|
|
69
|
+
if (!appDir) return false;
|
|
70
|
+
return SCHEMA_EXTENSIONS.some((ext) => existsSync(join(appDir, `schema${ext}`)));
|
|
50
71
|
}
|
|
51
72
|
|
|
52
73
|
/**
|
|
@@ -59,7 +80,8 @@ function collectRoutes(
|
|
|
59
80
|
node: SegmentNode,
|
|
60
81
|
ancestorParams: ParamEntry[],
|
|
61
82
|
ancestorParamsFiles: string[],
|
|
62
|
-
routes: RouteEntry[]
|
|
83
|
+
routes: RouteEntry[],
|
|
84
|
+
hasSchema: boolean
|
|
63
85
|
): void {
|
|
64
86
|
// TIM-834: Identify this segment's own params.ts (if it has a
|
|
65
87
|
// segmentParams export). The full chain of params.ts files in the
|
|
@@ -83,6 +105,7 @@ function collectRoutes(
|
|
|
83
105
|
const legacyFallback = ownParamsFile ? undefined : findLegacyParamsExport(node);
|
|
84
106
|
params.push({
|
|
85
107
|
name: node.paramName,
|
|
108
|
+
bracketName: node.segmentName,
|
|
86
109
|
type: paramTypeForSegment(node.segmentType),
|
|
87
110
|
// Codec chain populated at leaf time. We carry the per-segment
|
|
88
111
|
// legacy fallback (if any) so leaf-time resolution can fall back
|
|
@@ -117,6 +140,7 @@ function collectRoutes(
|
|
|
117
140
|
: undefined;
|
|
118
141
|
return {
|
|
119
142
|
name: p.name,
|
|
143
|
+
bracketName: p.bracketName,
|
|
120
144
|
type: p.type,
|
|
121
145
|
codecFilePaths,
|
|
122
146
|
};
|
|
@@ -127,6 +151,7 @@ function collectRoutes(
|
|
|
127
151
|
params: resolvedParams,
|
|
128
152
|
hasSearchParams: false,
|
|
129
153
|
isApiRoute,
|
|
154
|
+
hasSchema,
|
|
130
155
|
};
|
|
131
156
|
|
|
132
157
|
// Detect searchParams export from params.ts (primary) or page.tsx (fallback)
|
|
@@ -145,12 +170,12 @@ function collectRoutes(
|
|
|
145
170
|
|
|
146
171
|
// Recurse into children
|
|
147
172
|
for (const child of node.children) {
|
|
148
|
-
collectRoutes(child, params, nextAncestorFiles, routes);
|
|
173
|
+
collectRoutes(child, params, nextAncestorFiles, routes, hasSchema);
|
|
149
174
|
}
|
|
150
175
|
|
|
151
176
|
// Recurse into slots (they share the parent's URL path, but may have their own pages)
|
|
152
177
|
for (const slot of Object.values(node.slots)) {
|
|
153
|
-
collectRoutes(slot, params, nextAncestorFiles, routes);
|
|
178
|
+
collectRoutes(slot, params, nextAncestorFiles, routes, hasSchema);
|
|
154
179
|
}
|
|
155
180
|
}
|
|
156
181
|
|
|
@@ -205,10 +230,71 @@ function findLegacyParamsExport(node: SegmentNode): string | undefined {
|
|
|
205
230
|
return undefined;
|
|
206
231
|
}
|
|
207
232
|
|
|
233
|
+
/**
|
|
234
|
+
* Emit the schema-based type exports: TimberSegmentParams, TimberRoute,
|
|
235
|
+
* AllSegmentParams, RoutesWithParams.
|
|
236
|
+
*
|
|
237
|
+
* These types use InferCodecType references so they stay in sync with
|
|
238
|
+
* the schema without hardcoding resolved types.
|
|
239
|
+
*
|
|
240
|
+
* Design doc: design/41-global-params.md §Codegen Output
|
|
241
|
+
*/
|
|
242
|
+
function formatSchemaTypes(routes: RouteEntry[]): string[] {
|
|
243
|
+
const lines: string[] = [];
|
|
244
|
+
|
|
245
|
+
lines.push('/** Union of all valid route patterns. */');
|
|
246
|
+
lines.push('export type TimberRoute = keyof TimberSegmentParams;');
|
|
247
|
+
lines.push('');
|
|
248
|
+
lines.push('/** Maps each route pattern to its accumulated segment params. */');
|
|
249
|
+
lines.push('export interface TimberSegmentParams {');
|
|
250
|
+
|
|
251
|
+
for (const route of routes) {
|
|
252
|
+
if (route.params.length === 0) {
|
|
253
|
+
lines.push(` '${route.urlPath}': {};`);
|
|
254
|
+
} else {
|
|
255
|
+
lines.push(` '${route.urlPath}': {`);
|
|
256
|
+
for (const p of route.params) {
|
|
257
|
+
lines.push(` ${p.name}: ${buildSchemaParamType(p)};`);
|
|
258
|
+
}
|
|
259
|
+
lines.push(' };');
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
lines.push('}');
|
|
264
|
+
lines.push('');
|
|
265
|
+
|
|
266
|
+
// AllSegmentParams — flat union of all param types across all routes
|
|
267
|
+
lines.push('/** All segment params (union of all route params). */');
|
|
268
|
+
lines.push('export type AllSegmentParams = {');
|
|
269
|
+
lines.push(
|
|
270
|
+
' [K in keyof TimberSegmentParams as TimberSegmentParams[K] extends Record<string, never>'
|
|
271
|
+
);
|
|
272
|
+
lines.push(' ? never');
|
|
273
|
+
lines.push(' : K]: TimberSegmentParams[K];');
|
|
274
|
+
lines.push('} extends infer U');
|
|
275
|
+
lines.push(' ? { [K in keyof U[keyof U]]: U[keyof U][K] }');
|
|
276
|
+
lines.push(' : never;');
|
|
277
|
+
lines.push('');
|
|
278
|
+
|
|
279
|
+
// RoutesWithParams — filter routes by required param keys
|
|
280
|
+
lines.push(
|
|
281
|
+
'/** Filters TimberRoute to routes whose accumulated params include all listed keys. */'
|
|
282
|
+
);
|
|
283
|
+
lines.push('export type RoutesWithParams<K extends keyof AllSegmentParams> = {');
|
|
284
|
+
lines.push(' [R in TimberRoute]: K extends keyof TimberSegmentParams[R] ? R : never;');
|
|
285
|
+
lines.push('}[TimberRoute];');
|
|
286
|
+
|
|
287
|
+
return lines;
|
|
288
|
+
}
|
|
289
|
+
|
|
208
290
|
/**
|
|
209
291
|
* Format the collected routes into a TypeScript declaration file.
|
|
210
292
|
*/
|
|
211
|
-
function formatDeclarationFile(
|
|
293
|
+
function formatDeclarationFile(
|
|
294
|
+
routes: RouteEntry[],
|
|
295
|
+
importBase?: string,
|
|
296
|
+
hasSchema?: boolean
|
|
297
|
+
): string {
|
|
212
298
|
const lines: string[] = [];
|
|
213
299
|
|
|
214
300
|
lines.push('// This file is auto-generated by timber.js route map codegen.');
|
|
@@ -221,16 +307,29 @@ function formatDeclarationFile(routes: RouteEntry[], importBase?: string): strin
|
|
|
221
307
|
lines.push('export {};');
|
|
222
308
|
lines.push('');
|
|
223
309
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
310
|
+
if (hasSchema) {
|
|
311
|
+
// Schema-based codegen: import schema and InferCodecType
|
|
312
|
+
lines.push("import type schema from '../app/schema';");
|
|
313
|
+
lines.push("import type { InferCodecType } from '@timber-js/app/params';");
|
|
314
|
+
lines.push('');
|
|
315
|
+
// Emit TimberSegmentParams, TimberRoute, AllSegmentParams, RoutesWithParams
|
|
316
|
+
lines.push(...formatSchemaTypes(routes));
|
|
317
|
+
lines.push('');
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (!hasSchema) {
|
|
321
|
+
// TIM-834 P2: emit the shared codec-resolution helper type ONCE so the
|
|
322
|
+
// per-param chain conditionals reference it instead of inlining the
|
|
323
|
+
// fallback in both branches (which grows O(2^N) in chain depth).
|
|
324
|
+
lines.push(emitResolveSegmentFieldHelper());
|
|
325
|
+
lines.push('');
|
|
326
|
+
}
|
|
327
|
+
|
|
229
328
|
lines.push("declare module '@timber-js/app' {");
|
|
230
329
|
lines.push(' interface Routes {');
|
|
231
330
|
|
|
232
331
|
for (const route of routes) {
|
|
233
|
-
const paramsType = formatParamsType(route.params, importBase);
|
|
332
|
+
const paramsType = formatParamsType(route.params, importBase, hasSchema);
|
|
234
333
|
const searchParamsType = formatSearchParamsType(route, importBase);
|
|
235
334
|
|
|
236
335
|
lines.push(` '${route.urlPath}': {`);
|
|
@@ -243,15 +342,22 @@ function formatDeclarationFile(routes: RouteEntry[], importBase?: string): strin
|
|
|
243
342
|
lines.push('}');
|
|
244
343
|
lines.push('');
|
|
245
344
|
|
|
246
|
-
// Generate @timber-js/app/server augmentation — typed searchParams() generic
|
|
247
345
|
const pageRoutes = routes.filter((r) => !r.isApiRoute);
|
|
346
|
+
const dynamicRoutes = routes.filter((r) => r.params.length > 0);
|
|
248
347
|
|
|
249
|
-
//
|
|
250
|
-
|
|
251
|
-
|
|
348
|
+
// Generate @timber-js/app/server augmentation — typed getSegmentParams
|
|
349
|
+
if (dynamicRoutes.length > 0) {
|
|
350
|
+
lines.push("declare module '@timber-js/app/server' {");
|
|
351
|
+
for (const route of dynamicRoutes) {
|
|
352
|
+
const paramsType = formatParamsType(route.params, importBase, hasSchema);
|
|
353
|
+
lines.push(` export function getSegmentParams(route: '${route.urlPath}'): ${paramsType}`);
|
|
354
|
+
}
|
|
355
|
+
lines.push(' export function getSegmentParams(): Record<string, string | string[]>');
|
|
356
|
+
lines.push('}');
|
|
357
|
+
lines.push('');
|
|
358
|
+
}
|
|
252
359
|
|
|
253
360
|
// Generate overloads for @timber-js/app/client
|
|
254
|
-
const dynamicRoutes = routes.filter((r) => r.params.length > 0);
|
|
255
361
|
|
|
256
362
|
if (dynamicRoutes.length > 0 || pageRoutes.length > 0) {
|
|
257
363
|
lines.push("declare module '@timber-js/app/client' {");
|
|
@@ -263,7 +369,7 @@ function formatDeclarationFile(routes: RouteEntry[], importBase?: string): strin
|
|
|
263
369
|
// useParams overloads
|
|
264
370
|
if (dynamicRoutes.length > 0) {
|
|
265
371
|
for (const route of dynamicRoutes) {
|
|
266
|
-
const paramsType = formatParamsType(route.params, importBase);
|
|
372
|
+
const paramsType = formatParamsType(route.params, importBase, hasSchema);
|
|
267
373
|
lines.push(` export function useSegmentParams(route: '${route.urlPath}'): ${paramsType}`);
|
|
268
374
|
}
|
|
269
375
|
lines.push(' export function useSegmentParams(): Record<string, string | string[]>');
|
|
@@ -290,7 +396,7 @@ function formatDeclarationFile(routes: RouteEntry[], importBase?: string): strin
|
|
|
290
396
|
// confusing "Type 'string' is not assignable to type 'never'" cascade.
|
|
291
397
|
if (pageRoutes.length > 0) {
|
|
292
398
|
lines.push(' // Typed Link overloads — per-route (block 1 / emitted first)');
|
|
293
|
-
lines.push(...formatTypedLinkOverloads(pageRoutes, importBase));
|
|
399
|
+
lines.push(...formatTypedLinkOverloads(pageRoutes, importBase, hasSchema));
|
|
294
400
|
lines.push('');
|
|
295
401
|
}
|
|
296
402
|
|
|
@@ -315,13 +421,15 @@ function formatDeclarationFile(routes: RouteEntry[], importBase?: string): strin
|
|
|
315
421
|
/**
|
|
316
422
|
* Format the params type for a route entry.
|
|
317
423
|
*/
|
|
318
|
-
function formatParamsType(params: ParamEntry[], importBase?: string): string {
|
|
424
|
+
function formatParamsType(params: ParamEntry[], importBase?: string, hasSchema?: boolean): string {
|
|
319
425
|
if (params.length === 0) {
|
|
320
426
|
return '{}';
|
|
321
427
|
}
|
|
322
428
|
|
|
323
429
|
const fields = params.map((p) => {
|
|
324
|
-
const codecType =
|
|
430
|
+
const codecType = hasSchema
|
|
431
|
+
? buildSchemaParamType(p)
|
|
432
|
+
: buildCodecChainType(p, importBase, p.type);
|
|
325
433
|
return `${p.name}: ${codecType}`;
|
|
326
434
|
});
|
|
327
435
|
return `{ ${fields.join('; ')} }`;
|
|
@@ -24,7 +24,11 @@
|
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
import type { ParamEntry, RouteEntry } from './codegen-types.js';
|
|
27
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
buildCodecChainType,
|
|
29
|
+
buildSchemaParamType,
|
|
30
|
+
formatSearchParamsType,
|
|
31
|
+
} from './codegen-shared.js';
|
|
28
32
|
|
|
29
33
|
/** Shared Link base-props type literal used in every emitted call signature. */
|
|
30
34
|
export const LINK_BASE_PROPS_TYPE =
|
|
@@ -67,14 +71,20 @@ export function buildResolvedPattern(route: RouteEntry): string | null {
|
|
|
67
71
|
* When the segment's params chain (TIM-834) declares a typed codec, the
|
|
68
72
|
* inferred type from the codec wins via a nested conditional.
|
|
69
73
|
*/
|
|
70
|
-
export function formatLinkParamsType(
|
|
74
|
+
export function formatLinkParamsType(
|
|
75
|
+
params: ParamEntry[],
|
|
76
|
+
importBase?: string,
|
|
77
|
+
hasSchema?: boolean
|
|
78
|
+
): string {
|
|
71
79
|
if (params.length === 0) {
|
|
72
80
|
return '{}';
|
|
73
81
|
}
|
|
74
82
|
|
|
75
83
|
const fields = params.map((p) => {
|
|
76
84
|
const fallback = p.type === 'string' ? 'string | number' : p.type;
|
|
77
|
-
const codecType =
|
|
85
|
+
const codecType = hasSchema
|
|
86
|
+
? buildSchemaParamType(p)
|
|
87
|
+
: buildCodecChainType(p, importBase, fallback);
|
|
78
88
|
return `${p.name}: ${codecType}`;
|
|
79
89
|
});
|
|
80
90
|
return `{ ${fields.join('; ')} }`;
|
|
@@ -192,7 +202,11 @@ export function formatLinkCatchAllOverloads(): string[] {
|
|
|
192
202
|
* so the resolved-template-vs-pattern ordering reduces to placing the
|
|
193
203
|
* pattern variant before the resolved-template variant inside the union.
|
|
194
204
|
*/
|
|
195
|
-
export function formatTypedLinkOverloads(
|
|
205
|
+
export function formatTypedLinkOverloads(
|
|
206
|
+
routes: RouteEntry[],
|
|
207
|
+
importBase?: string,
|
|
208
|
+
hasSchema?: boolean
|
|
209
|
+
): string[] {
|
|
196
210
|
const lines: string[] = [];
|
|
197
211
|
const baseProps = LINK_BASE_PROPS_TYPE;
|
|
198
212
|
|
|
@@ -215,7 +229,7 @@ export function formatTypedLinkOverloads(routes: RouteEntry[], importBase?: stri
|
|
|
215
229
|
// segmentParams. `Record<string, unknown>` keeps generic spreads
|
|
216
230
|
// compiling and gives a readable error if a non-object is passed.
|
|
217
231
|
const paramsProp = hasDynamicParams
|
|
218
|
-
? `segmentParams: ${formatLinkParamsType(route.params, importBase)}`
|
|
232
|
+
? `segmentParams: ${formatLinkParamsType(route.params, importBase, hasSchema)}`
|
|
219
233
|
: 'segmentParams?: Record<string, unknown>';
|
|
220
234
|
|
|
221
235
|
const searchParamsType = route.hasSearchParams
|
|
@@ -59,6 +59,13 @@ export interface RequestContextStore {
|
|
|
59
59
|
* See design/07-routing.md §"params.ts — Convention File for Typed Params"
|
|
60
60
|
*/
|
|
61
61
|
segmentParams?: Record<string, string | string[]>;
|
|
62
|
+
/**
|
|
63
|
+
* The matched route pattern (e.g. '/[artistSlug]/[year]').
|
|
64
|
+
* Set by the pipeline alongside segmentParams. Used by
|
|
65
|
+
* getSegmentParams() for dev-mode route mismatch validation.
|
|
66
|
+
* See design/41-global-params.md §Runtime Validation
|
|
67
|
+
*/
|
|
68
|
+
matchedRoutePattern?: string;
|
|
62
69
|
/** Outgoing Set-Cookie entries (name → serialized value + options). Last write wins. */
|
|
63
70
|
cookieJar: Map<string, CookieEntry>;
|
|
64
71
|
/** Whether the response has flushed (headers committed). */
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-shot `setImmediate` coalescing primitive.
|
|
3
|
+
*
|
|
4
|
+
* Multiple synchronous `schedule()` calls within one event-loop tick
|
|
5
|
+
* result in exactly one `flush` invocation at the end of that tick.
|
|
6
|
+
* No recursive scheduling, no busy-wait, no polling loop.
|
|
7
|
+
*
|
|
8
|
+
* Both `createBufferedTransformStream` (Web) and
|
|
9
|
+
* `createNodeBufferedTransform` (Node) use this helper so the invariant
|
|
10
|
+
* — and this comment — live in one place.
|
|
11
|
+
*
|
|
12
|
+
* **Not a polling loop.** Uses a single-shot `setImmediate` per flush
|
|
13
|
+
* cycle — no recursive scheduling, no busy-wait. See design/02
|
|
14
|
+
* §"No Polling".
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface TickCoalescer {
|
|
18
|
+
/**
|
|
19
|
+
* Request a flush at the end of the current tick. If a flush is already
|
|
20
|
+
* scheduled, this is a no-op. Returns the pending promise (or undefined
|
|
21
|
+
* if the flush already fired and no new one has been scheduled).
|
|
22
|
+
*/
|
|
23
|
+
schedule(): void;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The pending flush promise, if any. Callers (e.g. `flush()` in a
|
|
27
|
+
* TransformStream) can `await` this to wait for the scheduled flush
|
|
28
|
+
* to complete before closing the stream.
|
|
29
|
+
*/
|
|
30
|
+
readonly pending: Promise<void> | undefined;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Cancel any pending scheduled flush and invoke the flush callback
|
|
34
|
+
* synchronously. Used by Node `Transform.flush()` which needs to
|
|
35
|
+
* drain the buffer immediately before calling back.
|
|
36
|
+
*/
|
|
37
|
+
flushSync(): void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Create a tick coalescer that calls `onFlush` at most once per tick.
|
|
42
|
+
*/
|
|
43
|
+
export function createTickCoalescer(onFlush: () => void): TickCoalescer {
|
|
44
|
+
let pending: Promise<void> | undefined;
|
|
45
|
+
let immediateHandle: ReturnType<typeof setImmediate> | null = null;
|
|
46
|
+
|
|
47
|
+
function doFlush(): void {
|
|
48
|
+
immediateHandle = null;
|
|
49
|
+
pending = undefined;
|
|
50
|
+
onFlush();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
schedule() {
|
|
55
|
+
if (immediateHandle !== null) return;
|
|
56
|
+
pending = new Promise<void>((resolve) => {
|
|
57
|
+
immediateHandle = setImmediate(() => {
|
|
58
|
+
doFlush();
|
|
59
|
+
resolve();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
get pending() {
|
|
65
|
+
return pending;
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
flushSync() {
|
|
69
|
+
if (immediateHandle !== null) {
|
|
70
|
+
clearImmediate(immediateHandle);
|
|
71
|
+
doFlush();
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -15,6 +15,7 @@ import type { ManifestSegmentNode } from './route-matcher.js';
|
|
|
15
15
|
import type { ClientBootstrapConfig } from './html-injectors.js';
|
|
16
16
|
import type { LayoutEntry } from './deny-renderer.js';
|
|
17
17
|
import type { GlobalErrorFile } from './rsc-entry/error-renderer.js';
|
|
18
|
+
import { swallow } from './logger.js';
|
|
18
19
|
import { logRenderError } from './logger.js';
|
|
19
20
|
import { loadModule } from './safe-load.js';
|
|
20
21
|
import { sourceMapError } from './dev-source-map.js';
|
|
@@ -105,9 +106,8 @@ export async function renderDevErrorPage(error: unknown, projectRoot?: string):
|
|
|
105
106
|
'</head>',
|
|
106
107
|
' <script type="module" src="/@vite/client"></script>\n</head>'
|
|
107
108
|
);
|
|
108
|
-
} catch {
|
|
109
|
-
|
|
110
|
-
// fall back to a minimal error page.
|
|
109
|
+
} catch (e) {
|
|
110
|
+
swallow(e, 'dev error page template failed, using minimal fallback');
|
|
111
111
|
html = minimalErrorPage(err);
|
|
112
112
|
}
|
|
113
113
|
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
} from './html-injector-core.js';
|
|
32
32
|
import { flightChunkScript } from './flight-scripts.js';
|
|
33
33
|
import { withTimeout, RenderTimeoutError } from './render-timeout.js';
|
|
34
|
+
import { createTickCoalescer } from './coalesce-tick.js';
|
|
34
35
|
|
|
35
36
|
// ─── Buffered Transform ──────────────────────────────────────────────────────
|
|
36
37
|
|
|
@@ -58,11 +59,11 @@ export function createBufferedTransformStream(
|
|
|
58
59
|
options: BufferedTransformOptions = {}
|
|
59
60
|
): TransformStream<Uint8Array, Uint8Array> {
|
|
60
61
|
const buffer = new BufferAggregator(options.maxBufferByteLength ?? Infinity);
|
|
61
|
-
let
|
|
62
|
+
let controller: TransformStreamDefaultController<Uint8Array> | null = null;
|
|
62
63
|
|
|
63
|
-
const drainTo = (
|
|
64
|
+
const drainTo = () => {
|
|
64
65
|
const merged = buffer.drain();
|
|
65
|
-
if (!merged) return;
|
|
66
|
+
if (!merged || !controller) return;
|
|
66
67
|
try {
|
|
67
68
|
controller.enqueue(merged);
|
|
68
69
|
} catch {
|
|
@@ -70,32 +71,20 @@ export function createBufferedTransformStream(
|
|
|
70
71
|
}
|
|
71
72
|
};
|
|
72
73
|
|
|
74
|
+
const coalescer = createTickCoalescer(drainTo);
|
|
75
|
+
|
|
73
76
|
return new TransformStream<Uint8Array, Uint8Array>({
|
|
74
|
-
transform(chunk,
|
|
77
|
+
transform(chunk, ctrl) {
|
|
78
|
+
controller = ctrl;
|
|
75
79
|
const overCap = buffer.append(chunk);
|
|
76
80
|
if (overCap) {
|
|
77
|
-
|
|
78
|
-
drainTo(controller);
|
|
81
|
+
drainTo();
|
|
79
82
|
return;
|
|
80
83
|
}
|
|
81
|
-
|
|
82
|
-
// Single-shot setImmediate — fires once at the end of this tick,
|
|
83
|
-
// then the promise resolves. NOT a recursive loop. See design/02
|
|
84
|
-
// §"No Polling".
|
|
85
|
-
pendingFlush = new Promise<void>((resolve) => {
|
|
86
|
-
setImmediate(() => {
|
|
87
|
-
try {
|
|
88
|
-
drainTo(controller);
|
|
89
|
-
} finally {
|
|
90
|
-
pendingFlush = undefined;
|
|
91
|
-
resolve();
|
|
92
|
-
}
|
|
93
|
-
});
|
|
94
|
-
});
|
|
84
|
+
coalescer.schedule();
|
|
95
85
|
},
|
|
96
86
|
flush() {
|
|
97
|
-
|
|
98
|
-
return pendingFlush;
|
|
87
|
+
return coalescer.pending;
|
|
99
88
|
},
|
|
100
89
|
});
|
|
101
90
|
}
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
suffixFlush,
|
|
34
34
|
} from './html-injector-core.js';
|
|
35
35
|
import { logStreamingError } from './logger.js';
|
|
36
|
+
import { createTickCoalescer } from './coalesce-tick.js';
|
|
36
37
|
|
|
37
38
|
// ─── Buffered Transform ──────────────────────────────────────────────────────
|
|
38
39
|
|
|
@@ -58,37 +59,26 @@ export interface NodeBufferedTransformOptions {
|
|
|
58
59
|
*/
|
|
59
60
|
export function createNodeBufferedTransform(options: NodeBufferedTransformOptions = {}): Transform {
|
|
60
61
|
const buffer = new BufferAggregator(options.maxBufferByteLength ?? Infinity);
|
|
61
|
-
let pendingImmediate: ReturnType<typeof setImmediate> | null = null;
|
|
62
62
|
|
|
63
63
|
const flushBuffer = () => {
|
|
64
64
|
const merged = buffer.drain();
|
|
65
65
|
if (merged) transform.push(merged);
|
|
66
66
|
};
|
|
67
67
|
|
|
68
|
+
const coalescer = createTickCoalescer(flushBuffer);
|
|
69
|
+
|
|
68
70
|
const transform = new Transform({
|
|
69
71
|
transform(chunk: Buffer, _encoding, callback) {
|
|
70
72
|
const overCap = buffer.append(chunk);
|
|
71
73
|
if (overCap) {
|
|
72
|
-
// Buffer too large to hold — flush synchronously now.
|
|
73
74
|
flushBuffer();
|
|
74
|
-
} else
|
|
75
|
-
|
|
76
|
-
// Single-shot setImmediate — NOT a recursive loop. See design/02
|
|
77
|
-
// §"No Polling".
|
|
78
|
-
pendingImmediate = setImmediate(() => {
|
|
79
|
-
pendingImmediate = null;
|
|
80
|
-
flushBuffer();
|
|
81
|
-
});
|
|
75
|
+
} else {
|
|
76
|
+
coalescer.schedule();
|
|
82
77
|
}
|
|
83
78
|
callback();
|
|
84
79
|
},
|
|
85
80
|
flush(callback) {
|
|
86
|
-
|
|
87
|
-
if (pendingImmediate) {
|
|
88
|
-
clearImmediate(pendingImmediate);
|
|
89
|
-
pendingImmediate = null;
|
|
90
|
-
}
|
|
91
|
-
flushBuffer();
|
|
81
|
+
coalescer.flushSync();
|
|
92
82
|
callback();
|
|
93
83
|
},
|
|
94
84
|
});
|
|
@@ -12,23 +12,45 @@
|
|
|
12
12
|
* 404 page. See design/07-routing.md §"Where Coercion Runs".
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import type { Codec } from '../codec.js';
|
|
16
|
+
import { toBracketKey } from '../params/resolve-schema.js';
|
|
15
17
|
import type { RouteMatch } from './pipeline.js';
|
|
16
18
|
import { sanitizeParamValue } from './pipeline-helpers.js';
|
|
17
19
|
import { loadModule } from './safe-load.js';
|
|
18
20
|
import { ParamCoercionError } from './route-element-builder.js';
|
|
19
21
|
import { isDebug } from './debug.js';
|
|
20
22
|
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Module-level global codec store (TIM-931)
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
/** Global schema codecs, set once at startup from virtual:timber-schema. */
|
|
28
|
+
let _globalCodecs: Record<string, Codec<unknown>> | null = null;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Register the global schema codecs. Called once from the RSC entry
|
|
32
|
+
* at server startup when app/schema.ts provides segment param codecs.
|
|
33
|
+
* @internal
|
|
34
|
+
*/
|
|
35
|
+
export function setGlobalSchemaCodecs(codecs: Record<string, Codec<unknown>> | null): void {
|
|
36
|
+
_globalCodecs = codecs;
|
|
37
|
+
}
|
|
38
|
+
|
|
21
39
|
/**
|
|
22
40
|
* Run segment param coercion on the matched route's segments.
|
|
23
41
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
42
|
+
* When `globalCodecs` is provided (from app/schema.ts), uses the global
|
|
43
|
+
* codec map keyed by bare param name. Otherwise falls back to loading
|
|
44
|
+
* per-segment params.ts modules.
|
|
45
|
+
*
|
|
26
46
|
* Throws ParamCoercionError if any codec fails (→ 404).
|
|
27
47
|
*
|
|
28
48
|
* This runs BEFORE middleware, so ctx.segmentParams is already typed.
|
|
29
49
|
* See design/07-routing.md §"Where Coercion Runs"
|
|
50
|
+
* See design/41-global-params.md §"Pipeline Integration"
|
|
30
51
|
*/
|
|
31
52
|
export async function coerceSegmentParams(match: RouteMatch): Promise<void> {
|
|
53
|
+
const globalCodecs = _globalCodecs;
|
|
32
54
|
// Unconditionally install a null-prototype target so the invariant
|
|
33
55
|
// "match.segmentParams is null-prototype" holds from the first line,
|
|
34
56
|
// regardless of whether any segment has a codec.
|
|
@@ -40,6 +62,37 @@ export async function coerceSegmentParams(match: RouteMatch): Promise<void> {
|
|
|
40
62
|
}
|
|
41
63
|
match.segmentParams = mergeTarget as RouteMatch['segmentParams'];
|
|
42
64
|
|
|
65
|
+
// TIM-931/TIM-936: When a global schema is available, use it for coercion
|
|
66
|
+
// instead of per-segment params.ts files. The global codec map is keyed
|
|
67
|
+
// by bracket name (e.g. '[id]', '[...slug]') to avoid collisions between
|
|
68
|
+
// distinct segment types that share a param name.
|
|
69
|
+
if (globalCodecs) {
|
|
70
|
+
for (const segment of match.segments) {
|
|
71
|
+
if (!segment.paramName) continue;
|
|
72
|
+
const bracketKey = toBracketKey(segment.segmentType, segment.paramName);
|
|
73
|
+
const codec = globalCodecs[bracketKey];
|
|
74
|
+
if (!codec) continue; // no codec in schema → keep raw string
|
|
75
|
+
|
|
76
|
+
const key = segment.paramName;
|
|
77
|
+
try {
|
|
78
|
+
mergeTarget[key] = sanitizeParamValue(codec.parse(mergeTarget[key] as string | string[]));
|
|
79
|
+
} catch (err) {
|
|
80
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
81
|
+
if (isDebug()) {
|
|
82
|
+
console.warn(
|
|
83
|
+
`[timber] Global schema codec rejected value for param "${key}"\n` +
|
|
84
|
+
` Error: ${message}\n` +
|
|
85
|
+
` Raw value: ${JSON.stringify(mergeTarget[key])}\n` +
|
|
86
|
+
` Hint: check the codec in app/schema.ts for key '${bracketKey}'.`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
throw new ParamCoercionError(message);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Legacy path: per-segment params.ts coercion
|
|
43
96
|
for (const segment of match.segments) {
|
|
44
97
|
// Only process segments that have a params.ts convention file
|
|
45
98
|
if (!segment.params) continue;
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
applyRequestHeaderOverlay,
|
|
21
21
|
setMutableCookieContext,
|
|
22
22
|
setSegmentParams,
|
|
23
|
+
setMatchedRoutePattern,
|
|
23
24
|
} from './request-context.js';
|
|
24
25
|
import { withSpan } from './tracing.js';
|
|
25
26
|
import { logRenderError } from './logger.js';
|
|
@@ -418,6 +419,13 @@ export async function handleRequest(
|
|
|
418
419
|
// See design/07-routing.md §"params.ts — Convention File for Typed Params"
|
|
419
420
|
setSegmentParams(match.segmentParams);
|
|
420
421
|
|
|
422
|
+
// Store the matched route pattern for dev-mode validation in getSegmentParams().
|
|
423
|
+
// The leaf segment's urlPath is the accumulated route pattern.
|
|
424
|
+
const leaf = match.segments[match.segments.length - 1];
|
|
425
|
+
if (leaf) {
|
|
426
|
+
setMatchedRoutePattern(leaf.urlPath);
|
|
427
|
+
}
|
|
428
|
+
|
|
421
429
|
// Bypass middleware for synthetic re-render requests built by the
|
|
422
430
|
// action-dispatch wrapper after a no-JS form validation failure.
|
|
423
431
|
// The wrapper has already executed middleware once on the inbound POST;
|