@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
|
@@ -45,11 +45,10 @@ import { buildClientScripts } from '../html-injectors.js';
|
|
|
45
45
|
import type { InterceptionContext, PipelineConfig, RouteMatch } from '../pipeline.js';
|
|
46
46
|
import { createPipeline } from '../pipeline.js';
|
|
47
47
|
import { coerceSegmentParams, setGlobalSchemaCodecs } from '../param-coercion.js';
|
|
48
|
-
import { setSegmentParams } from '../request-context.js';
|
|
49
|
-
import { buildRouteElement } from '../route-element-builder.js';
|
|
50
48
|
import type { ManifestSegmentNode } from '../route-matcher.js';
|
|
51
49
|
import { createMetadataRouteMatcher, createRouteMatcher } from '../route-matcher.js';
|
|
52
50
|
import { initDevTracing } from '../tracing.js';
|
|
51
|
+
import { createRevalidateRendererFactory } from './revalidate-renderer.js';
|
|
53
52
|
|
|
54
53
|
import { renderFallbackError as renderFallback } from '../fallback-error.js';
|
|
55
54
|
import { loadInstrumentation } from '../instrumentation.js';
|
|
@@ -422,40 +421,21 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
|
|
|
422
421
|
// action POSTs) matches the page pipeline exactly. Defaults to `true`
|
|
423
422
|
// both here and inside the wrapper.
|
|
424
423
|
stripTrailingSlash: pipelineConfig.stripTrailingSlash,
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
// Without this, components receive raw strings instead of typed values.
|
|
441
|
-
await coerceSegmentParams(revalidateMatch);
|
|
442
|
-
// Set coerced params in ALS so getSegmentParams() works during the
|
|
443
|
-
// revalidation render (AccessGate reads params via ALS). Without this,
|
|
444
|
-
// AccessGate → getSegmentParams() throws because segmentParams
|
|
445
|
-
// is never set. See TIM-667.
|
|
446
|
-
setSegmentParams(revalidateMatch.segmentParams);
|
|
447
|
-
const routeResult = await buildRouteElement(
|
|
448
|
-
revalidateReq,
|
|
449
|
-
revalidateMatch,
|
|
450
|
-
undefined,
|
|
451
|
-
null,
|
|
452
|
-
(buildManifest as BuildManifest).metadataRouteHashes
|
|
453
|
-
);
|
|
454
|
-
return {
|
|
455
|
-
element: routeResult.element,
|
|
456
|
-
headElements: routeResult.headElements,
|
|
457
|
-
};
|
|
458
|
-
},
|
|
424
|
+
// Revalidation renderer — runs the target route's `middleware.ts`
|
|
425
|
+
// chain BEFORE building the element tree so a server action that
|
|
426
|
+
// revalidates a protected path honors that path's auth, tenant
|
|
427
|
+
// isolation, and request-header injection. Closes the silent-bypass
|
|
428
|
+
// bug where `revalidatePath('/admin')` from a public action would
|
|
429
|
+
// serialize the admin RSC tree without ever consulting admin's
|
|
430
|
+
// middleware. Also validates the user-supplied `path` as a local
|
|
431
|
+
// root-relative URL to reject `revalidatePath('//evil.com/admin')`
|
|
432
|
+
// and similar scheme/authority injections. See TIM-996 and
|
|
433
|
+
// design/08-forms-and-actions.md §"revalidatePath()".
|
|
434
|
+
buildRevalidateRenderer: createRevalidateRendererFactory({
|
|
435
|
+
matchRoute,
|
|
436
|
+
coerceSegmentParams,
|
|
437
|
+
buildManifest: buildManifest as BuildManifest,
|
|
438
|
+
}),
|
|
459
439
|
});
|
|
460
440
|
|
|
461
441
|
return { handler, pipelineConfig };
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Revalidation renderer — builds the React element tree for the target
|
|
3
|
+
* path requested by `revalidatePath()` from inside a server action, with
|
|
4
|
+
* the same security guarantees as a normal route render.
|
|
5
|
+
*
|
|
6
|
+
* Before this module existed, `revalidatePath()` matched the route, coerced
|
|
7
|
+
* params, and called `buildRouteElement()` directly. That bypassed the
|
|
8
|
+
* target route's `middleware.ts` chain entirely. An action that revalidated
|
|
9
|
+
* a higher-privilege or user-controlled path could leak an authenticated
|
|
10
|
+
* RSC tree to the action submitter even when middleware on that path
|
|
11
|
+
* would have denied them.
|
|
12
|
+
*
|
|
13
|
+
* The fix (TIM-996) is to run the target route's middleware chain inside
|
|
14
|
+
* a fresh `runWithRequestContext` scope BEFORE building the element tree,
|
|
15
|
+
* propagate `DenySignal` and `RedirectSignal` to `executeAction`, drop the
|
|
16
|
+
* revalidation when middleware returns a short-circuit `Response`, and
|
|
17
|
+
* lift the request-header overlay onto the action's ALS store so the
|
|
18
|
+
* revalidated component render sees middleware-injected headers via
|
|
19
|
+
* `getHeaders()`.
|
|
20
|
+
*
|
|
21
|
+
* The input `path` is also validated as a local root-relative pathname so
|
|
22
|
+
* `revalidatePath('//evil.com/admin')` and similar scheme/authority
|
|
23
|
+
* injections cannot smuggle a foreign host through `new URL(path, base)`.
|
|
24
|
+
*
|
|
25
|
+
* See design/08-forms-and-actions.md §"revalidatePath()" and
|
|
26
|
+
* design/13-security.md §"Server actions silently bypass middleware".
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { BuildManifest } from '../build-manifest.js';
|
|
30
|
+
import { canonicalize } from '../canonicalize.js';
|
|
31
|
+
import { getCookiesForSsr, seedRequestCookies } from '../cookie-context.js';
|
|
32
|
+
import { logRenderError } from '../logger.js';
|
|
33
|
+
import type { RouteMatch, RouteMatcher } from '../pipeline.js';
|
|
34
|
+
import { DenySignal, RedirectSignal } from '../primitives.js';
|
|
35
|
+
import { applyRequestHeaderOverlay, setSegmentParams } from '../request-context.js';
|
|
36
|
+
import { requestContextAls } from '../als-registry.js';
|
|
37
|
+
import { buildRouteElement } from '../route-element-builder.js';
|
|
38
|
+
import type { RevalidateRenderer, RevalidationResult } from '../actions.js';
|
|
39
|
+
|
|
40
|
+
import { runMiddlewareForAction, type CoerceSegmentParamsFn } from './action-middleware-runner.js';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Sentinel thrown when revalidation cannot proceed because middleware
|
|
44
|
+
* returned a short-circuit `Response` (a non-redirect HTTP response that
|
|
45
|
+
* we cannot translate back to an element tree). `executeAction` catches
|
|
46
|
+
* this and drops the revalidation — the action result is still delivered,
|
|
47
|
+
* but no `_tree` is piggybacked onto the response. The matching name
|
|
48
|
+
* `RevalidationDropped` is exported so callers can identify it via
|
|
49
|
+
* `instanceof` without leaking the message text to clients.
|
|
50
|
+
*/
|
|
51
|
+
export class RevalidationDropped extends Error {
|
|
52
|
+
constructor(message: string) {
|
|
53
|
+
super(message);
|
|
54
|
+
this.name = 'RevalidationDropped';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Result of validating a `revalidatePath()` argument. */
|
|
59
|
+
type PathValidation =
|
|
60
|
+
| { ok: true; pathname: string; search: string }
|
|
61
|
+
| { ok: false; reason: string };
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Validate a `revalidatePath()` argument.
|
|
65
|
+
*
|
|
66
|
+
* Accepts local root-relative pathnames only. Rejects anything that
|
|
67
|
+
* `new URL(path, base)` would resolve to a different host or scheme:
|
|
68
|
+
*
|
|
69
|
+
* - Empty / non-string input
|
|
70
|
+
* - Anything not starting with `/` (relative, scheme-bearing, etc.)
|
|
71
|
+
* - Protocol-relative `//evil.com/admin` — resolves to scheme://evil.com
|
|
72
|
+
* - Backslash-prefixed `/\evil.com` — some clients fold `\` into `/`
|
|
73
|
+
* - Control characters and null bytes
|
|
74
|
+
* - Paths that fail canonicalization (encoded separators, `..` escaping
|
|
75
|
+
* root, malformed percent-encoding)
|
|
76
|
+
*
|
|
77
|
+
* The pathname is canonicalized so the rest of the renderer matches the
|
|
78
|
+
* same shape the page pipeline matches.
|
|
79
|
+
*/
|
|
80
|
+
export function validateRevalidatePath(path: unknown): PathValidation {
|
|
81
|
+
if (typeof path !== 'string' || path.length === 0) {
|
|
82
|
+
return { ok: false, reason: 'must be a non-empty string' };
|
|
83
|
+
}
|
|
84
|
+
if (!path.startsWith('/')) {
|
|
85
|
+
return { ok: false, reason: 'must start with "/" (root-relative path)' };
|
|
86
|
+
}
|
|
87
|
+
if (path.startsWith('//')) {
|
|
88
|
+
return { ok: false, reason: 'must not start with "//" (protocol-relative URL)' };
|
|
89
|
+
}
|
|
90
|
+
if (path.startsWith('/\\')) {
|
|
91
|
+
return { ok: false, reason: 'must not start with "/\\" (backslash injection)' };
|
|
92
|
+
}
|
|
93
|
+
for (let i = 0; i < path.length; i++) {
|
|
94
|
+
const code = path.charCodeAt(i);
|
|
95
|
+
if (code <= 0x1f || code === 0x7f) {
|
|
96
|
+
return { ok: false, reason: 'must not contain control characters' };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const hashIndex = path.indexOf('#');
|
|
100
|
+
// Fragments are meaningless server-side and would not be sent back to
|
|
101
|
+
// the client on revalidation anyway. Drop them silently before splitting
|
|
102
|
+
// the query so a `#` cannot smuggle additional path content past the
|
|
103
|
+
// canonicalizer.
|
|
104
|
+
const noFragment = hashIndex >= 0 ? path.slice(0, hashIndex) : path;
|
|
105
|
+
const queryIndex = noFragment.indexOf('?');
|
|
106
|
+
const pathnameOnly = queryIndex >= 0 ? noFragment.slice(0, queryIndex) : noFragment;
|
|
107
|
+
const search = queryIndex >= 0 ? noFragment.slice(queryIndex) : '';
|
|
108
|
+
const canonical = canonicalize(pathnameOnly, true);
|
|
109
|
+
if (!canonical.ok) {
|
|
110
|
+
return { ok: false, reason: `failed URL canonicalization (status ${canonical.status})` };
|
|
111
|
+
}
|
|
112
|
+
return { ok: true, pathname: canonical.pathname, search };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Serialize a cookie map into a `Cookie:` request header value
|
|
117
|
+
* (`name1=value1; name2=value2`).
|
|
118
|
+
*
|
|
119
|
+
* Cookie values are RFC 6265 cookie-octet (no `;`, no `,`, no whitespace,
|
|
120
|
+
* no `"` / `\`) by construction — `assertValidCookieValue()` rejects
|
|
121
|
+
* anything else at write time. Names are validated similarly. Joining
|
|
122
|
+
* with `; ` is therefore safe and cannot be used to smuggle additional
|
|
123
|
+
* cookies via a malformed value.
|
|
124
|
+
*/
|
|
125
|
+
function serializeCookieMap(cookies: Map<string, string>): string {
|
|
126
|
+
const parts: string[] = [];
|
|
127
|
+
cookies.forEach((value, name) => {
|
|
128
|
+
parts.push(`${name}=${value}`);
|
|
129
|
+
});
|
|
130
|
+
return parts.join('; ');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Dependencies for the revalidate renderer factory. */
|
|
134
|
+
export interface RevalidateRendererDeps {
|
|
135
|
+
matchRoute: RouteMatcher;
|
|
136
|
+
coerceSegmentParams: CoerceSegmentParamsFn;
|
|
137
|
+
buildManifest: BuildManifest;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Build a per-request `RevalidateRenderer`.
|
|
142
|
+
*
|
|
143
|
+
* The returned renderer:
|
|
144
|
+
* 1. Validates the requested path.
|
|
145
|
+
* 2. Matches the canonicalized path against the route tree.
|
|
146
|
+
* 3. Coerces segment params via the matched chain's `params.ts` codecs.
|
|
147
|
+
* 4. Runs the matched route's `middleware.ts` chain inside a fresh
|
|
148
|
+
* `runWithRequestContext` scope (via `runMiddlewareForAction`).
|
|
149
|
+
* Deny/redirect signals are re-thrown so `executeAction` can apply
|
|
150
|
+
* its existing redirect handling and the new deny-drop behavior.
|
|
151
|
+
* A short-circuit `Response` becomes `RevalidationDropped`.
|
|
152
|
+
* 5. Lifts the request-header overlay onto the OUTER action's ALS store
|
|
153
|
+
* so the revalidated component render (which happens later inside
|
|
154
|
+
* `renderToReadableStream` in the action's scope) observes
|
|
155
|
+
* middleware-injected headers via `getHeaders()`. Acceptable because
|
|
156
|
+
* revalidation runs after the action body has returned — the only
|
|
157
|
+
* remaining ALS readers are render-phase code that should see the
|
|
158
|
+
* same overlay a normal page render would see.
|
|
159
|
+
* 6. Builds and returns the route element tree, ready for the action
|
|
160
|
+
* handler to serialize alongside the action result.
|
|
161
|
+
*
|
|
162
|
+
* Cookies set inside the revalidation middleware are intentionally NOT
|
|
163
|
+
* propagated to the action response. The middleware on a *different* path
|
|
164
|
+
* setting cookies via the action's response is surprising semantics; the
|
|
165
|
+
* scoped cookie jar on the inner ALS run is discarded with the scope.
|
|
166
|
+
* The primary security boundary closed here is auth — `deny()` from the
|
|
167
|
+
* target's middleware now prevents the revalidated tree from leaking.
|
|
168
|
+
*/
|
|
169
|
+
export function createRevalidateRendererFactory(
|
|
170
|
+
deps: RevalidateRendererDeps
|
|
171
|
+
): (req: Request) => RevalidateRenderer {
|
|
172
|
+
return (req: Request): RevalidateRenderer => {
|
|
173
|
+
return async (path: string): Promise<RevalidationResult> => {
|
|
174
|
+
// ── 1. Validate the path ────────────────────────────────────────
|
|
175
|
+
const validation = validateRevalidatePath(path);
|
|
176
|
+
if (!validation.ok) {
|
|
177
|
+
// Throwing a typed error here drops the revalidation. We do not
|
|
178
|
+
// surface the message to the client (logRenderError sinks it).
|
|
179
|
+
const err = new RevalidationDropped(
|
|
180
|
+
`revalidatePath(${JSON.stringify(path)}) rejected: ${validation.reason}`
|
|
181
|
+
);
|
|
182
|
+
logRenderError({ method: req.method, path: new URL(req.url).pathname, error: err });
|
|
183
|
+
throw err;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── 2. Build the revalidation Request on the canonical pathname ─
|
|
187
|
+
//
|
|
188
|
+
// Use the canonical pathname (not the raw input) so the rebuilt
|
|
189
|
+
// URL cannot carry a hostile authority — `new URL('//evil.com/x',
|
|
190
|
+
// base)` would otherwise resolve to scheme://evil.com/x. Forward
|
|
191
|
+
// the original action request's headers (session IDs, accept-
|
|
192
|
+
// language, etc.) so middleware on the target route sees the same
|
|
193
|
+
// identity the action did.
|
|
194
|
+
//
|
|
195
|
+
// The `Cookie:` header is REBUILT from the action's
|
|
196
|
+
// read-your-own-writes cookie state rather than copied from the
|
|
197
|
+
// inbound request. An action that rotates the session via
|
|
198
|
+
// `getCookieJar().set('session', newToken)` and then calls
|
|
199
|
+
// `revalidatePath('/dashboard')` should have the target route's
|
|
200
|
+
// middleware see the NEW session — otherwise a session-rotation
|
|
201
|
+
// action would succeed only to have the piggybacked revalidation
|
|
202
|
+
// redirect to /login. `getCookieJar()` writes only update the ALS
|
|
203
|
+
// jar, not the immutable `Request.headers`, so without this step
|
|
204
|
+
// middleware reading `ctx.req.headers.get('cookie')` sees stale
|
|
205
|
+
// values. Codex P2.
|
|
206
|
+
const baseUrl = new URL(req.url);
|
|
207
|
+
const revalidateUrl = new URL(baseUrl.origin);
|
|
208
|
+
revalidateUrl.pathname = validation.pathname;
|
|
209
|
+
revalidateUrl.search = validation.search;
|
|
210
|
+
const revalidateHeaders = new Headers(req.headers);
|
|
211
|
+
revalidateHeaders.set('Accept', 'text/x-component');
|
|
212
|
+
// Snapshot the action's post-mutation cookie map (deletions
|
|
213
|
+
// included) and serialize it into a `Cookie:` header value the
|
|
214
|
+
// target middleware can read off `ctx.req.headers.get('cookie')`.
|
|
215
|
+
// Only attempt this inside a request context — `getCookiesForSsr`
|
|
216
|
+
// throws otherwise — and bail out gracefully if no context is
|
|
217
|
+
// active (the legacy call-site contract permitted this).
|
|
218
|
+
const inRequestContext = Boolean(requestContextAls.getStore());
|
|
219
|
+
const ryw = inRequestContext ? getCookiesForSsr() : new Map<string, string>();
|
|
220
|
+
if (inRequestContext) {
|
|
221
|
+
if (ryw.size === 0) {
|
|
222
|
+
revalidateHeaders.delete('cookie');
|
|
223
|
+
} else {
|
|
224
|
+
revalidateHeaders.set('cookie', serializeCookieMap(ryw));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const revalidateReq = new Request(revalidateUrl.toString(), {
|
|
228
|
+
headers: revalidateHeaders,
|
|
229
|
+
});
|
|
230
|
+
// Seed the parsed map directly so the child `runWithRequestContext`
|
|
231
|
+
// inside `runMiddlewareForAction` skips `parseCookieHeader` — the
|
|
232
|
+
// same H-3 smuggling primitive mitigated by TIM-868. The Cookie
|
|
233
|
+
// header above remains in place for code that reads
|
|
234
|
+
// `ctx.req.headers.get('cookie')` directly.
|
|
235
|
+
if (inRequestContext) {
|
|
236
|
+
seedRequestCookies(revalidateReq, new Map(ryw));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ── 3. Match the canonical path ─────────────────────────────────
|
|
240
|
+
const match = deps.matchRoute(validation.pathname);
|
|
241
|
+
if (!match) {
|
|
242
|
+
throw new Error(`revalidatePath('${path}') — no matching route`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ── 4. Run middleware in a fresh ALS scope ──────────────────────
|
|
246
|
+
//
|
|
247
|
+
// We only run middleware when the matched chain has at least one
|
|
248
|
+
// entry. Routes with no middleware skip this step (no scope, no
|
|
249
|
+
// overlay) — matching the page pipeline's behavior in
|
|
250
|
+
// `handleRequest` where `runMiddlewarePhase` is gated on
|
|
251
|
+
// `middlewareChain.length > 0`.
|
|
252
|
+
let overlay: Headers | undefined;
|
|
253
|
+
if (match.middlewareChain.length > 0) {
|
|
254
|
+
const outcome = await runMiddlewareForAction(
|
|
255
|
+
revalidateReq,
|
|
256
|
+
match,
|
|
257
|
+
deps.coerceSegmentParams
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
if (outcome.kind === 'param-coercion-error') {
|
|
261
|
+
// Bad params on the target path — drop revalidation. The
|
|
262
|
+
// action's own response still goes back to the client.
|
|
263
|
+
throw new RevalidationDropped(
|
|
264
|
+
`revalidatePath('${path}') — segment param coercion failed`
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
if (outcome.kind === 'error') {
|
|
268
|
+
// Re-throw unhandled middleware errors so `executeAction`'s
|
|
269
|
+
// existing `console.error` fallback logs them. Not surfaced
|
|
270
|
+
// to the action client (revalidation is best-effort).
|
|
271
|
+
throw outcome.error;
|
|
272
|
+
}
|
|
273
|
+
if (outcome.kind === 'redirect') {
|
|
274
|
+
// Propagate the redirect — `executeAction` catches
|
|
275
|
+
// RedirectSignal and converts it to an action-level redirect.
|
|
276
|
+
// See actions.ts §"Process path revalidation".
|
|
277
|
+
throw outcome.signal;
|
|
278
|
+
}
|
|
279
|
+
if (outcome.kind === 'deny') {
|
|
280
|
+
// Propagate the deny — `executeAction` catches DenySignal
|
|
281
|
+
// (added in this PR) and drops the revalidation.
|
|
282
|
+
throw outcome.signal;
|
|
283
|
+
}
|
|
284
|
+
if (outcome.kind === 'short-circuit') {
|
|
285
|
+
// Middleware returned a Response directly. A redirect Response
|
|
286
|
+
// (`return Response.redirect('/login')` or a 3xx + Location)
|
|
287
|
+
// is the same outcome as throwing `redirect()` — common for
|
|
288
|
+
// session-expiry / feature-flag gates — and the action client
|
|
289
|
+
// should follow it. Translate to a `RedirectSignal` so
|
|
290
|
+
// `executeAction` surfaces it as the action redirect, matching
|
|
291
|
+
// the normal page-pipeline behavior promised in
|
|
292
|
+
// design/08-forms-and-actions.md §"revalidatePath()". Codex P2.
|
|
293
|
+
const status = outcome.response.status;
|
|
294
|
+
const location = outcome.response.headers.get('location');
|
|
295
|
+
if (status >= 300 && status < 400 && location) {
|
|
296
|
+
throw new RedirectSignal(location, status);
|
|
297
|
+
}
|
|
298
|
+
// Any other short-circuit Response (200/403/418/etc.) cannot
|
|
299
|
+
// be translated to a piggy-backed element tree. Drop the
|
|
300
|
+
// revalidation; the action result still ships.
|
|
301
|
+
throw new RevalidationDropped(
|
|
302
|
+
`revalidatePath('${path}') — middleware returned a short-circuit Response (status ${status})`
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// `continue` — capture the overlay so we can apply it to the
|
|
307
|
+
// action's request-context store below. We deliberately do NOT
|
|
308
|
+
// forward `outcome.cookies` or `outcome.setCookieHeaders` here;
|
|
309
|
+
// see the module docstring for why.
|
|
310
|
+
overlay = outcome.overlay;
|
|
311
|
+
} else {
|
|
312
|
+
// No middleware on the target — still coerce params so segment
|
|
313
|
+
// params reach `setSegmentParams` typed. `runMiddlewareForAction`
|
|
314
|
+
// would have done this for us; do it manually on the bypass path.
|
|
315
|
+
await deps.coerceSegmentParams(match);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ── 5. Lift state onto the action's ALS store ───────────────────
|
|
319
|
+
//
|
|
320
|
+
// Segment params are set unconditionally so `getSegmentParams()`
|
|
321
|
+
// inside the revalidated tree returns the target path's coerced
|
|
322
|
+
// params, not the action route's. The overlay is applied only
|
|
323
|
+
// when middleware ran and produced headers — otherwise we leave
|
|
324
|
+
// the action's `headers()` view untouched.
|
|
325
|
+
setSegmentParams(match.segmentParams);
|
|
326
|
+
if (overlay) {
|
|
327
|
+
applyRequestHeaderOverlay(overlay);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ── 6. Build the route element tree ─────────────────────────────
|
|
331
|
+
const routeResult = await buildRouteElement(
|
|
332
|
+
revalidateReq,
|
|
333
|
+
match,
|
|
334
|
+
undefined,
|
|
335
|
+
null,
|
|
336
|
+
deps.buildManifest.metadataRouteHashes
|
|
337
|
+
);
|
|
338
|
+
return {
|
|
339
|
+
element: routeResult.element,
|
|
340
|
+
headElements: routeResult.headElements,
|
|
341
|
+
};
|
|
342
|
+
};
|
|
343
|
+
};
|
|
344
|
+
}
|
|
@@ -36,6 +36,8 @@ import { callSsr } from './ssr-bridge.js';
|
|
|
36
36
|
import type { RenderSignals } from './rsc-stream.js';
|
|
37
37
|
import { recordTiming } from '../server-timing.js';
|
|
38
38
|
import { teeWithErrorPropagation } from '../stream-utils.js';
|
|
39
|
+
import { addSpanEventSync } from '../tracing.js';
|
|
40
|
+
import { isDevMode } from '../debug.js';
|
|
39
41
|
|
|
40
42
|
/**
|
|
41
43
|
* Maximum number of queueMicrotask yields during signal promotion.
|
|
@@ -46,6 +48,19 @@ import { teeWithErrorPropagation } from '../stream-utils.js';
|
|
|
46
48
|
*/
|
|
47
49
|
const SIGNAL_PROMOTION_MAX_YIELDS = 5;
|
|
48
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Number of additional microtask yields used by the cap-reached detection
|
|
53
|
+
* phase (TIM-970). After the promotion loop burns its full cap without
|
|
54
|
+
* capturing a signal, we yield a few more times to distinguish "no signal
|
|
55
|
+
* was ever coming" (state 2) from "signal arrived one microtask past the
|
|
56
|
+
* cap" (state 3 — the silent-miss path). These yields are telemetry-only:
|
|
57
|
+
* if a late signal is observed it triggers a warn/span-event but is NOT
|
|
58
|
+
* promoted, because raising the effective promotion cap is deferred to
|
|
59
|
+
* the E.2 investigation. Without this phase, every default `deferSuspenseFor=0`
|
|
60
|
+
* render would emit a cap-reached warning even though nothing was missed.
|
|
61
|
+
*/
|
|
62
|
+
const SIGNAL_PROMOTION_DETECTION_EXTRA_YIELDS = 3;
|
|
63
|
+
|
|
49
64
|
/**
|
|
50
65
|
* Test-only observable: records how many microtask yields were consumed
|
|
51
66
|
* during the last signal promotion check. Import this in tests to assert
|
|
@@ -57,6 +72,20 @@ const SIGNAL_PROMOTION_MAX_YIELDS = 5;
|
|
|
57
72
|
*/
|
|
58
73
|
export const __test_only_signalPromotionDepth: { value: number } = { value: 0 };
|
|
59
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Test-only observable: true when the promotion loop hit the cap without
|
|
77
|
+
* capturing a signal (the silent-miss path — state 3 in TIM-970). False
|
|
78
|
+
* when the loop captured a signal at any depth, when it was skipped via
|
|
79
|
+
* `_fizzAllReadyResolved`, or when no signal was ever expected at a depth
|
|
80
|
+
* shallower than the cap.
|
|
81
|
+
*
|
|
82
|
+
* Pairs with `__test_only_signalPromotionDepth`: depth alone cannot
|
|
83
|
+
* distinguish "cap hit with signal captured on yield 5" from "cap hit with
|
|
84
|
+
* signal arriving on yield 6+" — both read `.value === 5`. This flag closes
|
|
85
|
+
* that gap. See TIM-970, design/05-streaming.md §"Signal Promotion".
|
|
86
|
+
*/
|
|
87
|
+
export const __test_only_signalPromotionCapHit: { value: boolean } = { value: false };
|
|
88
|
+
|
|
60
89
|
interface SsrRenderOptions {
|
|
61
90
|
req: Request;
|
|
62
91
|
rscStream: ReadableStream<Uint8Array>;
|
|
@@ -261,14 +290,61 @@ export async function renderSsrResponse(opts: SsrRenderOptions): Promise<Respons
|
|
|
261
290
|
// See design/05-streaming.md §"deferSuspenseFor and the Hold Window"
|
|
262
291
|
if (!signals.redirectSignal && !signals.denySignal && !signals.renderError) {
|
|
263
292
|
let yieldsConsumed = 0;
|
|
293
|
+
let detectionConfirmedLateSignal = false;
|
|
264
294
|
if (!navContext._fizzAllReadyResolved) {
|
|
295
|
+
// Promotion phase: bounded cap of SIGNAL_PROMOTION_MAX_YIELDS.
|
|
296
|
+
// Signals captured here are honored by checkCapturedSignals and
|
|
297
|
+
// become the response (redirect/deny/error page).
|
|
265
298
|
for (let i = 0; i < SIGNAL_PROMOTION_MAX_YIELDS; i++) {
|
|
266
299
|
await new Promise<void>((r) => queueMicrotask(r));
|
|
267
300
|
yieldsConsumed++;
|
|
268
301
|
if (signals.redirectSignal || signals.denySignal || signals.renderError) break;
|
|
269
302
|
}
|
|
303
|
+
|
|
304
|
+
// Detection phase (TIM-970): if the promotion cap was hit without
|
|
305
|
+
// capturing a signal, yield a few more times to disambiguate state 2
|
|
306
|
+
// ("no signal was ever coming" — the common legitimate-200 case) from
|
|
307
|
+
// state 3 ("signal arrived one microtask past the cap" — the silent
|
|
308
|
+
// miss). Telemetry-only: if a late signal is observed it triggers
|
|
309
|
+
// the warning and span event but is NOT promoted. Promoting it would
|
|
310
|
+
// effectively raise the cap to MAX_YIELDS + DETECTION_EXTRA_YIELDS,
|
|
311
|
+
// which the ticket defers to the E.2 investigation. The signals are
|
|
312
|
+
// cleared so checkCapturedSignals does not pick them up.
|
|
313
|
+
if (
|
|
314
|
+
yieldsConsumed === SIGNAL_PROMOTION_MAX_YIELDS &&
|
|
315
|
+
!signals.redirectSignal &&
|
|
316
|
+
!signals.denySignal &&
|
|
317
|
+
!signals.renderError
|
|
318
|
+
) {
|
|
319
|
+
for (let i = 0; i < SIGNAL_PROMOTION_DETECTION_EXTRA_YIELDS; i++) {
|
|
320
|
+
await new Promise<void>((r) => queueMicrotask(r));
|
|
321
|
+
if (signals.redirectSignal || signals.denySignal || signals.renderError) {
|
|
322
|
+
detectionConfirmedLateSignal = true;
|
|
323
|
+
signals.redirectSignal = null;
|
|
324
|
+
signals.denySignal = null;
|
|
325
|
+
signals.renderError = null;
|
|
326
|
+
break;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
270
330
|
}
|
|
271
331
|
__test_only_signalPromotionDepth.value = yieldsConsumed;
|
|
332
|
+
__test_only_signalPromotionCapHit.value = detectionConfirmedLateSignal;
|
|
333
|
+
|
|
334
|
+
if (detectionConfirmedLateSignal) {
|
|
335
|
+
if (isDevMode()) {
|
|
336
|
+
console.warn(
|
|
337
|
+
`[timber] Signal promotion cap reached (${SIGNAL_PROMOTION_MAX_YIELDS} microtask yields) — a deny()/redirect()/renderError signal arrived just past the cap and was discarded. ` +
|
|
338
|
+
`Likely cause: a page using deny()/redirect() inside a deeply-wrapped async component (custom withSpan, decorators, etc.). ` +
|
|
339
|
+
`Simplify the async chain or reduce withSpan/wrapper depth. ` +
|
|
340
|
+
`See design/05-streaming.md §"Signal Promotion".`
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
addSpanEventSync('timber.ssr.signal_promotion_cap_reached', {
|
|
344
|
+
depth: SIGNAL_PROMOTION_MAX_YIELDS,
|
|
345
|
+
signal_types_checked: 'redirect,deny,renderError',
|
|
346
|
+
});
|
|
347
|
+
}
|
|
272
348
|
}
|
|
273
349
|
|
|
274
350
|
const promoted = checkCapturedSignals(/* skipHandledDeny */ true);
|
|
@@ -75,11 +75,33 @@ export function createAutoSitemapHandler(
|
|
|
75
75
|
// In dev, this cache is busted by file changes (the handler is recreated
|
|
76
76
|
// when the route tree changes). In production, it's generated once.
|
|
77
77
|
let cachedResult: SitemapResult | null = null;
|
|
78
|
+
let inflight: Promise<SitemapResult> | null = null;
|
|
78
79
|
|
|
79
80
|
async function getOrGenerateSitemap(): Promise<SitemapResult> {
|
|
80
81
|
if (cachedResult) return cachedResult;
|
|
81
|
-
|
|
82
|
-
|
|
82
|
+
if (!inflight) {
|
|
83
|
+
inflight = generateSitemap(root, config, moduleLoader).then(
|
|
84
|
+
(result) => {
|
|
85
|
+
cachedResult = result;
|
|
86
|
+
inflight = null;
|
|
87
|
+
return result;
|
|
88
|
+
},
|
|
89
|
+
(err) => {
|
|
90
|
+
inflight = null;
|
|
91
|
+
throw err;
|
|
92
|
+
}
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
// Every caller races against its own timeout — not just the first.
|
|
96
|
+
// Per design/06-caching.md — singleflight maps must have a timeout
|
|
97
|
+
// to prevent deadlock if generateSitemap hangs.
|
|
98
|
+
const timeout = new Promise<never>((_, reject) =>
|
|
99
|
+
setTimeout(() => {
|
|
100
|
+
inflight = null;
|
|
101
|
+
reject(new Error('[timber] sitemap generation timed out'));
|
|
102
|
+
}, 30_000)
|
|
103
|
+
);
|
|
104
|
+
return Promise.race([inflight, timeout]);
|
|
83
105
|
}
|
|
84
106
|
|
|
85
107
|
return async (pathname: string): Promise<Response | null> => {
|
|
@@ -196,9 +196,14 @@ export async function resolveSlotElement(
|
|
|
196
196
|
// ─── Element Wrapping Helpers ───────────────────────────────────────────────
|
|
197
197
|
|
|
198
198
|
/**
|
|
199
|
-
* Wrap an element with error boundaries and layouts from
|
|
200
|
-
* slot segments (indices 1..n, skipping the slot root at index 0).
|
|
199
|
+
* Wrap an element with error boundaries, access gates, and layouts from
|
|
200
|
+
* intermediate slot segments (indices 1..n, skipping the slot root at index 0).
|
|
201
201
|
* Processes innermost-first to match route-element-builder.ts ordering.
|
|
202
|
+
*
|
|
203
|
+
* Access gates are enforced for every intermediate segment that has access.ts,
|
|
204
|
+
* not just the slot root. Without this, nested slot children (e.g.,
|
|
205
|
+
* @admin/reports/access.ts) and intercepting route children with access.ts
|
|
206
|
+
* would bypass authorization. See TIM-999.
|
|
202
207
|
*/
|
|
203
208
|
async function wrapWithIntermediateSegments(
|
|
204
209
|
chain: ManifestSegmentNode[],
|
|
@@ -208,6 +213,7 @@ async function wrapWithIntermediateSegments(
|
|
|
208
213
|
for (let i = chain.length - 1; i > 0; i--) {
|
|
209
214
|
const seg = chain[i];
|
|
210
215
|
element = await wrapSegmentWithErrorBoundaries(seg, element, h);
|
|
216
|
+
element = await wrapWithAccessGate(seg, element, h);
|
|
211
217
|
element = await wrapWithLayout(seg, element, h);
|
|
212
218
|
}
|
|
213
219
|
return element;
|
|
@@ -132,8 +132,10 @@ export function wrapSsrElement(
|
|
|
132
132
|
const withNav = createElement(SsrNavigationProvider, null, element);
|
|
133
133
|
|
|
134
134
|
// 2. TimberNuqsAdapter equivalent (wraps withNuqsSsrAdapter for the actual nuqs provider)
|
|
135
|
+
// eslint-disable-next-line react/no-children-prop -- createElement API
|
|
135
136
|
const withNuqs = createElement(SsrNuqsWrapper, { searchParams, children: withNav });
|
|
136
137
|
|
|
137
138
|
// 3. Outermost: NavigationRoot equivalent (PendingNavigationProvider + TopLoader)
|
|
139
|
+
// eslint-disable-next-line react/no-children-prop -- createElement API
|
|
138
140
|
return createElement(SsrNavigationRoot, { hasTopLoader, children: withNuqs });
|
|
139
141
|
}
|
|
@@ -302,10 +302,12 @@ export async function buildElementTree(config: TreeBuilderConfig): Promise<TreeB
|
|
|
302
302
|
}
|
|
303
303
|
}
|
|
304
304
|
|
|
305
|
+
/* eslint-disable react/no-children-prop -- createElement API */
|
|
305
306
|
element = createElement(LayoutComponent, {
|
|
306
307
|
...slotProps,
|
|
307
308
|
children: element,
|
|
308
309
|
});
|
|
310
|
+
/* eslint-enable react/no-children-prop */
|
|
309
311
|
}
|
|
310
312
|
}
|
|
311
313
|
}
|
package/src/server/types.ts
CHANGED
|
@@ -34,7 +34,7 @@ export interface RouteContext {
|
|
|
34
34
|
headers: Headers;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
37
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type, typescript/no-empty-interface
|
|
38
38
|
export interface AccessContext {}
|
|
39
39
|
|
|
40
40
|
export interface Metadata {
|
|
@@ -143,7 +143,7 @@ export interface Metadata {
|
|
|
143
143
|
|
|
144
144
|
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
145
145
|
export namespace MetadataRoute {
|
|
146
|
-
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
146
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type, typescript/no-empty-interface
|
|
147
147
|
export interface Sitemap extends Array<SitemapEntry> {}
|
|
148
148
|
export interface SitemapEntry {
|
|
149
149
|
url: string;
|