@rsc-kit/core 0.30.27 → 0.30.29
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/edgeShells.d.ts +52 -0
- package/dist/edgeShells.js +98 -0
- package/dist/edgeShells.js.map +1 -0
- package/dist/headers.d.ts +17 -4
- package/dist/headers.js +17 -4
- package/dist/headers.js.map +1 -1
- package/dist/host.js +161 -84
- package/dist/host.js.map +1 -1
- package/dist/vite.d.ts +15 -0
- package/dist/vite.js +63 -2
- package/dist/vite.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The url patterns of the pages an edge may serve from a cached shell, for
|
|
3
|
+
* `.output/ppr.json` - the marker of the PPR contract, which an edge reads the
|
|
4
|
+
* same way for every framework that implements it.
|
|
5
|
+
*
|
|
6
|
+
* An edge in front of the app asks one question of each request - is this a
|
|
7
|
+
* PPR page? - and answers it from a list it was handed at deploy time. Reading
|
|
8
|
+
* `rsc-static/` for it does not work: a guarded page has a shell on disk too,
|
|
9
|
+
* served from a cache it skips its guard; `_team_` cannot say whether it was a
|
|
10
|
+
* parameter or a folder of that name; and the layout is ours to change.
|
|
11
|
+
*
|
|
12
|
+
* So the list is the build's, by the same rule the shell endpoint applies: a
|
|
13
|
+
* page with a shell, on a route with no middleware - neither a `middleware.ts`
|
|
14
|
+
* above it nor the host's `export const middleware`. The endpoint refuses
|
|
15
|
+
* exactly those, so the list and the origin cannot disagree.
|
|
16
|
+
*/
|
|
17
|
+
import type { RouteSegment } from './manifest.js';
|
|
18
|
+
/** What an edge is promised, and what it can hold. */
|
|
19
|
+
export declare const MAX_SHELLS = 200;
|
|
20
|
+
export declare const MAX_SHELL_BYTES = 200;
|
|
21
|
+
/**
|
|
22
|
+
* The marker. `bypass` names this framework's own requests on a page's path
|
|
23
|
+
* that are not a visitor's document: every payload fetch - a navigation, a
|
|
24
|
+
* prefetch, a refresh - carries X-RSC, and an action is a POST, which an edge
|
|
25
|
+
* never answers from a shell anyway.
|
|
26
|
+
*/
|
|
27
|
+
export declare function pprMarker(build: string, shells: string[]): {
|
|
28
|
+
v: number;
|
|
29
|
+
build: string;
|
|
30
|
+
shells: string[];
|
|
31
|
+
bypass: {
|
|
32
|
+
headers: string[];
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
export interface ShellRoute {
|
|
36
|
+
component: string;
|
|
37
|
+
segments: RouteSegment[];
|
|
38
|
+
middleware?: readonly string[];
|
|
39
|
+
hostMiddleware?: readonly string[];
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The patterns, `/` for the index, `[name]` for one segment and a final
|
|
43
|
+
* `[...name]` for one or more - and what was left out of the list, and why,
|
|
44
|
+
* for the build to say.
|
|
45
|
+
*/
|
|
46
|
+
export declare function edgeShells(results: readonly {
|
|
47
|
+
type: string;
|
|
48
|
+
component: string;
|
|
49
|
+
}[], routes: readonly ShellRoute[]): {
|
|
50
|
+
shells: string[];
|
|
51
|
+
left: string[];
|
|
52
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The url patterns of the pages an edge may serve from a cached shell, for
|
|
3
|
+
* `.output/ppr.json` - the marker of the PPR contract, which an edge reads the
|
|
4
|
+
* same way for every framework that implements it.
|
|
5
|
+
*
|
|
6
|
+
* An edge in front of the app asks one question of each request - is this a
|
|
7
|
+
* PPR page? - and answers it from a list it was handed at deploy time. Reading
|
|
8
|
+
* `rsc-static/` for it does not work: a guarded page has a shell on disk too,
|
|
9
|
+
* served from a cache it skips its guard; `_team_` cannot say whether it was a
|
|
10
|
+
* parameter or a folder of that name; and the layout is ours to change.
|
|
11
|
+
*
|
|
12
|
+
* So the list is the build's, by the same rule the shell endpoint applies: a
|
|
13
|
+
* page with a shell, on a route with no middleware - neither a `middleware.ts`
|
|
14
|
+
* above it nor the host's `export const middleware`. The endpoint refuses
|
|
15
|
+
* exactly those, so the list and the origin cannot disagree.
|
|
16
|
+
*/
|
|
17
|
+
/** What an edge is promised, and what it can hold. */
|
|
18
|
+
export const MAX_SHELLS = 200;
|
|
19
|
+
export const MAX_SHELL_BYTES = 200;
|
|
20
|
+
/**
|
|
21
|
+
* The marker. `bypass` names this framework's own requests on a page's path
|
|
22
|
+
* that are not a visitor's document: every payload fetch - a navigation, a
|
|
23
|
+
* prefetch, a refresh - carries X-RSC, and an action is a POST, which an edge
|
|
24
|
+
* never answers from a shell anyway.
|
|
25
|
+
*/
|
|
26
|
+
export function pprMarker(build, shells) {
|
|
27
|
+
return { v: 1, build, shells, bypass: { headers: ['X-RSC'] } };
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The patterns, `/` for the index, `[name]` for one segment and a final
|
|
31
|
+
* `[...name]` for one or more - and what was left out of the list, and why,
|
|
32
|
+
* for the build to say.
|
|
33
|
+
*/
|
|
34
|
+
export function edgeShells(results, routes) {
|
|
35
|
+
const byComponent = new Map(routes.map((route) => [route.component, route]));
|
|
36
|
+
const shells = [];
|
|
37
|
+
const left = [];
|
|
38
|
+
for (const component of new Set(results.filter((r) => r.type === 'shell').map((r) => r.component))) {
|
|
39
|
+
const route = byComponent.get(component);
|
|
40
|
+
if (!route)
|
|
41
|
+
continue;
|
|
42
|
+
if (route.middleware?.length || route.hostMiddleware?.length)
|
|
43
|
+
continue;
|
|
44
|
+
// Bound from the request's host, which a path pattern cannot say: the
|
|
45
|
+
// same pattern would match every tenant's page on every domain.
|
|
46
|
+
if (route.segments.some((segment) => segment.type === 'host'))
|
|
47
|
+
continue;
|
|
48
|
+
const pattern = patternOf(route.segments);
|
|
49
|
+
if (pattern === null)
|
|
50
|
+
continue;
|
|
51
|
+
if (new TextEncoder().encode(pattern).length > MAX_SHELL_BYTES) {
|
|
52
|
+
left.push(`${pattern.slice(0, 60)}… is over ${MAX_SHELL_BYTES} bytes`);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (!shells.includes(pattern))
|
|
56
|
+
shells.push(pattern);
|
|
57
|
+
}
|
|
58
|
+
// Over the cap, the deepest are the ones not listed: an edge that does not
|
|
59
|
+
// know a page is a PPR page sends it to the origin, which answers it
|
|
60
|
+
// correctly, and the shallow pages are the ones most visited.
|
|
61
|
+
if (shells.length > MAX_SHELLS) {
|
|
62
|
+
shells.sort((a, b) => depth(a) - depth(b) || (a < b ? -1 : 1));
|
|
63
|
+
left.push(`${shells.length - MAX_SHELLS} past the shallowest ${MAX_SHELLS}`);
|
|
64
|
+
shells.length = MAX_SHELLS;
|
|
65
|
+
}
|
|
66
|
+
shells.sort();
|
|
67
|
+
return { shells, left };
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* A route's segments as the edge matches them, or null when it cannot: a
|
|
71
|
+
* catch-all anywhere but last, or a name that is itself bracketed - an
|
|
72
|
+
* optional catch-all, `[[...name]]`, matches no url at all.
|
|
73
|
+
*/
|
|
74
|
+
function patternOf(segments) {
|
|
75
|
+
const parts = [];
|
|
76
|
+
for (const [index, segment] of segments.entries()) {
|
|
77
|
+
// Not a path an edge can match: the contract has no empty, `.` or `..`
|
|
78
|
+
// segment, and a name with a slash would be two.
|
|
79
|
+
if (['', '.', '..'].includes(segment.value) || segment.value.includes('/'))
|
|
80
|
+
return null;
|
|
81
|
+
if (segment.type === 'static')
|
|
82
|
+
parts.push(segment.value);
|
|
83
|
+
else if (segment.value.includes('[') || segment.value.includes(']'))
|
|
84
|
+
return null;
|
|
85
|
+
else if (segment.type === 'catchAll') {
|
|
86
|
+
if (index !== segments.length - 1)
|
|
87
|
+
return null;
|
|
88
|
+
parts.push(`[...${segment.value}]`);
|
|
89
|
+
}
|
|
90
|
+
else
|
|
91
|
+
parts.push(`[${segment.value}]`);
|
|
92
|
+
}
|
|
93
|
+
return '/' + parts.join('/');
|
|
94
|
+
}
|
|
95
|
+
function depth(pattern) {
|
|
96
|
+
return pattern === '/' ? 0 : pattern.split('/').length - 1;
|
|
97
|
+
}
|
|
98
|
+
//# sourceMappingURL=edgeShells.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"edgeShells.js","sourceRoot":"","sources":["../src/edgeShells.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The url patterns of the pages an edge may serve from a cached shell, for\n * `.output/ppr.json` - the marker of the PPR contract, which an edge reads the\n * same way for every framework that implements it.\n *\n * An edge in front of the app asks one question of each request - is this a\n * PPR page? - and answers it from a list it was handed at deploy time. Reading\n * `rsc-static/` for it does not work: a guarded page has a shell on disk too,\n * served from a cache it skips its guard; `_team_` cannot say whether it was a\n * parameter or a folder of that name; and the layout is ours to change.\n *\n * So the list is the build's, by the same rule the shell endpoint applies: a\n * page with a shell, on a route with no middleware - neither a `middleware.ts`\n * above it nor the host's `export const middleware`. The endpoint refuses\n * exactly those, so the list and the origin cannot disagree.\n */\n\nimport type { RouteSegment } from './manifest.js'\n\n/** What an edge is promised, and what it can hold. */\nexport const MAX_SHELLS = 200\nexport const MAX_SHELL_BYTES = 200\n\n/**\n * The marker. `bypass` names this framework's own requests on a page's path\n * that are not a visitor's document: every payload fetch - a navigation, a\n * prefetch, a refresh - carries X-RSC, and an action is a POST, which an edge\n * never answers from a shell anyway.\n */\nexport function pprMarker(build: string, shells: string[]) {\n return { v: 1, build, shells, bypass: { headers: ['X-RSC'] } }\n}\n\nexport interface ShellRoute {\n component: string\n segments: RouteSegment[]\n middleware?: readonly string[]\n hostMiddleware?: readonly string[]\n}\n\n/**\n * The patterns, `/` for the index, `[name]` for one segment and a final\n * `[...name]` for one or more - and what was left out of the list, and why,\n * for the build to say.\n */\nexport function edgeShells(\n results: readonly { type: string; component: string }[],\n routes: readonly ShellRoute[],\n): { shells: string[]; left: string[] } {\n const byComponent = new Map(routes.map((route) => [route.component, route]))\n const shells: string[] = []\n const left: string[] = []\n\n for (const component of new Set(results.filter((r) => r.type === 'shell').map((r) => r.component))) {\n const route = byComponent.get(component)\n\n if (!route) continue\n if (route.middleware?.length || route.hostMiddleware?.length) continue\n\n // Bound from the request's host, which a path pattern cannot say: the\n // same pattern would match every tenant's page on every domain.\n if (route.segments.some((segment) => segment.type === 'host')) continue\n\n const pattern = patternOf(route.segments)\n\n if (pattern === null) continue\n\n if (new TextEncoder().encode(pattern).length > MAX_SHELL_BYTES) {\n left.push(`${pattern.slice(0, 60)}… is over ${MAX_SHELL_BYTES} bytes`)\n continue\n }\n\n if (!shells.includes(pattern)) shells.push(pattern)\n }\n\n // Over the cap, the deepest are the ones not listed: an edge that does not\n // know a page is a PPR page sends it to the origin, which answers it\n // correctly, and the shallow pages are the ones most visited.\n if (shells.length > MAX_SHELLS) {\n shells.sort((a, b) => depth(a) - depth(b) || (a < b ? -1 : 1))\n left.push(`${shells.length - MAX_SHELLS} past the shallowest ${MAX_SHELLS}`)\n shells.length = MAX_SHELLS\n }\n\n shells.sort()\n\n return { shells, left }\n}\n\n/**\n * A route's segments as the edge matches them, or null when it cannot: a\n * catch-all anywhere but last, or a name that is itself bracketed - an\n * optional catch-all, `[[...name]]`, matches no url at all.\n */\nfunction patternOf(segments: readonly RouteSegment[]): string | null {\n const parts: string[] = []\n\n for (const [index, segment] of segments.entries()) {\n // Not a path an edge can match: the contract has no empty, `.` or `..`\n // segment, and a name with a slash would be two.\n if (['', '.', '..'].includes(segment.value) || segment.value.includes('/')) return null\n if (segment.type === 'static') parts.push(segment.value)\n else if (segment.value.includes('[') || segment.value.includes(']')) return null\n else if (segment.type === 'catchAll') {\n if (index !== segments.length - 1) return null\n parts.push(`[...${segment.value}]`)\n } else parts.push(`[${segment.value}]`)\n }\n\n return '/' + parts.join('/')\n}\n\nfunction depth(pattern: string): number {\n return pattern === '/' ? 0 : pattern.split('/').length - 1\n}\n"]}
|
package/dist/headers.d.ts
CHANGED
|
@@ -70,10 +70,6 @@ export declare const HEADER: {
|
|
|
70
70
|
*/
|
|
71
71
|
readonly query: 'X-RSC-Query';
|
|
72
72
|
readonly queryPath: '/_rsc/query';
|
|
73
|
-
/** Build-time shell for a PPR route, for an edge cache to hold. */
|
|
74
|
-
readonly pprShellPath: '/_rsc/ppr-shell';
|
|
75
|
-
/** Finish a shell an edge cache is already serving. */
|
|
76
|
-
readonly pprResumePath: '/_rsc/ppr-resume';
|
|
77
73
|
/**
|
|
78
74
|
* On a refresh a change triggered: which names moved, to which versions
|
|
79
75
|
* (`stock@5,orders@2`). A shared section renders once per such change, and
|
|
@@ -126,3 +122,20 @@ export declare const REVALIDATE = "public, max-age=0, must-revalidate";
|
|
|
126
122
|
* cache may not keep it; the visitor's own browser may, and must ask again.
|
|
127
123
|
*/
|
|
128
124
|
export declare const PER_REQUEST = "private, max-age=0, must-revalidate";
|
|
125
|
+
/**
|
|
126
|
+
* The PPR contract: one marker, one shell endpoint, one resume, for any edge
|
|
127
|
+
* and any framework that implements it. Vendor-neutral names on purpose: an
|
|
128
|
+
* edge speaks the contract, and there is no second way in that is rsc-kit's
|
|
129
|
+
* own. docs/hosts/deployment.mdx has the whole of it.
|
|
130
|
+
*/
|
|
131
|
+
export declare const PPR: {
|
|
132
|
+
/** Reserved: nothing under it is the app's. */
|
|
133
|
+
readonly prefix: '/_ppr/';
|
|
134
|
+
readonly shellPath: '/_ppr/shell';
|
|
135
|
+
readonly resumePath: '/_ppr/resume';
|
|
136
|
+
/** The platform's per-release token. Unset, both endpoints are a 404. */
|
|
137
|
+
readonly tokenEnv: 'PPR_TOKEN';
|
|
138
|
+
readonly token: 'PPR-Token';
|
|
139
|
+
/** The build a shell or holes belong to - the marker's `build`. */
|
|
140
|
+
readonly build: 'PPR-Build';
|
|
141
|
+
};
|
package/dist/headers.js
CHANGED
|
@@ -79,10 +79,6 @@ export const HEADER = {
|
|
|
79
79
|
*/
|
|
80
80
|
query: 'X-RSC-Query',
|
|
81
81
|
queryPath: '/_rsc/query',
|
|
82
|
-
/** Build-time shell for a PPR route, for an edge cache to hold. */
|
|
83
|
-
pprShellPath: '/_rsc/ppr-shell',
|
|
84
|
-
/** Finish a shell an edge cache is already serving. */
|
|
85
|
-
pprResumePath: '/_rsc/ppr-resume',
|
|
86
82
|
/**
|
|
87
83
|
* On a refresh a change triggered: which names moved, to which versions
|
|
88
84
|
* (`stock@5,orders@2`). A shared section renders once per such change, and
|
|
@@ -141,4 +137,21 @@ export const REVALIDATE = 'public, max-age=0, must-revalidate';
|
|
|
141
137
|
* cache may not keep it; the visitor's own browser may, and must ask again.
|
|
142
138
|
*/
|
|
143
139
|
export const PER_REQUEST = 'private, max-age=0, must-revalidate';
|
|
140
|
+
/**
|
|
141
|
+
* The PPR contract: one marker, one shell endpoint, one resume, for any edge
|
|
142
|
+
* and any framework that implements it. Vendor-neutral names on purpose: an
|
|
143
|
+
* edge speaks the contract, and there is no second way in that is rsc-kit's
|
|
144
|
+
* own. docs/hosts/deployment.mdx has the whole of it.
|
|
145
|
+
*/
|
|
146
|
+
export const PPR = {
|
|
147
|
+
/** Reserved: nothing under it is the app's. */
|
|
148
|
+
prefix: '/_ppr/',
|
|
149
|
+
shellPath: '/_ppr/shell',
|
|
150
|
+
resumePath: '/_ppr/resume',
|
|
151
|
+
/** The platform's per-release token. Unset, both endpoints are a 404. */
|
|
152
|
+
tokenEnv: 'PPR_TOKEN',
|
|
153
|
+
token: 'PPR-Token',
|
|
154
|
+
/** The build a shell or holes belong to - the marker's `build`. */
|
|
155
|
+
build: 'PPR-Build',
|
|
156
|
+
};
|
|
144
157
|
//# sourceMappingURL=headers.js.map
|
package/dist/headers.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"headers.js","sourceRoot":"","sources":["../src/headers.ts"],"names":[],"mappings":"AAAA,2DAA2D;AAC3D,EAAE;AACF,0EAA0E;AAC1E,sEAAsE;AACtE,wEAAwE;AACxE,2EAA2E;AAC3E,gCAAgC;AAChC,EAAE;AACF,gFAAgF;AAEhF,MAAM,CAAC,MAAM,MAAM,GAAG;IACpB;;;OAGG;IACH,GAAG,EAAE,OAAO;IAEZ,iFAAiF;IACjF,QAAQ,EAAE,gBAAgB;IAE1B,kEAAkE;IAClE,YAAY,EAAE,qBAAqB;IAEnC,wCAAwC;IACxC,OAAO,EAAE,eAAe;IAExB;;;;OAIG;IACH,QAAQ,EAAE,iBAAiB;IAE3B,gFAAgF;IAChF,OAAO,EAAE,eAAe;IAExB,uDAAuD;IACvD,SAAS,EAAE,iBAAiB;IAE5B,gDAAgD;IAChD,OAAO,EAAE,eAAe;IAExB,uEAAuE;IACvE,UAAU,EAAE,kBAAkB;IAE9B,0CAA0C;IAC1C,MAAM,EAAE,cAAc;IAEtB;;;;;;;OAOG;IACH,QAAQ,EAAE,gBAAgB;IAE1B;;;;;;OAMG;IACH,WAAW,EAAE,oBAAoB;IAEjC;;;;;OAKG;IACH,UAAU,EAAE,cAAc;IAC1B;;;;;;;;;OASG;IACH;;;;;;;OAOG;IACH,KAAK,EAAE,aAAa;IAEpB,SAAS,EAAE,aAAa;IACxB
|
|
1
|
+
{"version":3,"file":"headers.js","sourceRoot":"","sources":["../src/headers.ts"],"names":[],"mappings":"AAAA,2DAA2D;AAC3D,EAAE;AACF,0EAA0E;AAC1E,sEAAsE;AACtE,wEAAwE;AACxE,2EAA2E;AAC3E,gCAAgC;AAChC,EAAE;AACF,gFAAgF;AAEhF,MAAM,CAAC,MAAM,MAAM,GAAG;IACpB;;;OAGG;IACH,GAAG,EAAE,OAAO;IAEZ,iFAAiF;IACjF,QAAQ,EAAE,gBAAgB;IAE1B,kEAAkE;IAClE,YAAY,EAAE,qBAAqB;IAEnC,wCAAwC;IACxC,OAAO,EAAE,eAAe;IAExB;;;;OAIG;IACH,QAAQ,EAAE,iBAAiB;IAE3B,gFAAgF;IAChF,OAAO,EAAE,eAAe;IAExB,uDAAuD;IACvD,SAAS,EAAE,iBAAiB;IAE5B,gDAAgD;IAChD,OAAO,EAAE,eAAe;IAExB,uEAAuE;IACvE,UAAU,EAAE,kBAAkB;IAE9B,0CAA0C;IAC1C,MAAM,EAAE,cAAc;IAEtB;;;;;;;OAOG;IACH,QAAQ,EAAE,gBAAgB;IAE1B;;;;;;OAMG;IACH,WAAW,EAAE,oBAAoB;IAEjC;;;;;OAKG;IACH,UAAU,EAAE,cAAc;IAC1B;;;;;;;;;OASG;IACH;;;;;;;OAOG;IACH,KAAK,EAAE,aAAa;IAEpB,SAAS,EAAE,aAAa;IACxB;;;;OAIG;IACH,SAAS,EAAE,eAAe;IAC1B,+EAA+E;IAC/E,WAAW,EAAE,eAAe;IAC5B,8FAA8F;IAC9F,UAAU,EAAE,cAAc;CAClB,CAAA;AAEV,sBAAsB;AACtB,MAAM,CAAC,MAAM,WAAW,GAAG,iCAAiC,CAAA;AAE5D,2BAA2B;AAC3B,MAAM,CAAC,MAAM,SAAS,GAAG,0BAA0B,CAAA;AAEnD;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG;IACzB,MAAM,CAAC,GAAG;IACV,MAAM,CAAC,QAAQ;IACf,MAAM,CAAC,UAAU;IACjB,MAAM,CAAC,SAAS;IAChB,MAAM,CAAC,OAAO;CACf,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;AAEZ;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,UAAU,GAAG,mBAAmB,CAAA;AAE7C;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,UAAU,GAAG,oCAAoC,CAAA;AAE9D;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,qCAAqC,CAAA;AAEhE;;;;;GAKG;AACH,MAAM,CAAC,MAAM,GAAG,GAAG;IACjB,+CAA+C;IAC/C,MAAM,EAAE,QAAQ;IAChB,SAAS,EAAE,aAAa;IACxB,UAAU,EAAE,cAAc;IAC1B,yEAAyE;IACzE,QAAQ,EAAE,WAAW;IACrB,KAAK,EAAE,WAAW;IAClB,mEAAmE;IACnE,KAAK,EAAE,WAAW;CACV,CAAA","sourcesContent":["// The wire protocol, as names rather than string literals.\n//\n// A host writing these out by hand is how they drift. Two of them are not\n// negotiable at all — the client sends them or asks for them at fixed\n// addresses, and a host that spells one differently fails silently: the\n// request falls through, the decoder never runs, and a button does nothing\n// with no error on either side.\n//\n// PROTOCOL.md describes what each one means. This is the same list, importable.\n\nexport const HEADER = {\n /**\n * Present on a request for a Flight payload rather than the document. One\n * url serves both, which is why every answer must Vary on it.\n */\n rsc: 'X-RSC',\n\n /** The layout chain the client has mounted, outermost first, comma-separated. */\n segments: 'X-RSC-Segments',\n\n /** The boundary the answer replaces. 0 means a whole document. */\n segmentDepth: 'X-RSC-Segment-Depth',\n\n /** The chain to send back next time. */\n layouts: 'X-RSC-Layouts',\n\n /**\n * On a payload request: this url's page said it does not exist, after the\n * shell went out, and the client is asking for the not-found page to put\n * where it was. RedirectBoundary sends it.\n */\n notFound: 'X-RSC-Not-Found',\n\n /** Identifies the build, so a client can notice it is talking to an old one. */\n version: 'X-RSC-Version',\n\n /** The slot an intercepted navigation is targeting. */\n intercept: 'X-RSC-Intercept',\n\n /** Where the navigation or action came from. */\n referer: 'X-RSC-Referer',\n\n /** One named region of the current page to render and return alone. */\n revalidate: 'X-RSC-Revalidate',\n\n /** The server reference being invoked. */\n action: 'X-RSC-Action',\n\n /**\n * Go here instead.\n *\n * Used for a redirect the client has to perform itself: a failed action, and\n * a navigation whose render redirected. Never a 3xx for those — `fetch`\n * follows one transparently, so the client would get the destination's HTML\n * where it expected a Flight payload and decode it as one.\n */\n redirect: 'X-RSC-Redirect',\n\n /**\n * The body's real type.\n *\n * The body itself goes out as application/octet-stream so a host that parses\n * multipart cannot consume it before the action does — reading Content-Type\n * instead leaves an upload undecodable.\n */\n contentType: 'X-RSC-Content-Type',\n\n /**\n * Where the browser posts server actions.\n *\n * Not a convention this can choose: the client runtime posts here\n * unconditionally.\n */\n actionPath: '/_rsc/action',\n /**\n * Where the browser reads queries.\n *\n * A GET, and that is the whole point of it being a separate address: a POST\n * is cacheable by nothing, so a read routed through the action endpoint can\n * never be kept by an HTTP cache, a CDN or a service worker. Only functions\n * declared with `query()` are reachable here — the endpoint checks the mark\n * before invoking anything, because otherwise this address would run any\n * registered action over GET.\n */\n /**\n * Sent on every read, and required by the query endpoint.\n *\n * A GET with no unusual header is a simple request, so any page could\n * trigger one with an <img> and the visitor's cookies would ride along.\n * This header is not CORS-safelisted, so the browser preflights it — the\n * same thing that makes a POST carrying X-RSC-Action safe.\n */\n query: 'X-RSC-Query',\n\n queryPath: '/_rsc/query',\n /**\n * On a refresh a change triggered: which names moved, to which versions\n * (`stock@5,orders@2`). A shared section renders once per such change, and\n * every tab asking with the same one gets that render.\n */\n changedBy: 'X-RSC-Changed',\n /** The stream a tab hears on when a name its page refreshes on has changed. */\n changesPath: '/_rsc/changes',\n /** Whether this renderer can serve: up, and its backend answering. GET only, never cached. */\n healthPath: '/_rsc/health',\n} as const\n\n/** Flight payload. */\nexport const FLIGHT_TYPE = 'text/x-component; charset=utf-8'\n\n/** A rendered document. */\nexport const HTML_TYPE = 'text/html; charset=utf-8'\n\n/**\n * Every request header that changes what a response body contains.\n *\n * One url answers with a whole document, a partial payload, a named region or\n * an interceptor depending on these — so a cache keyed on the url alone, or on\n * `X-RSC` alone, will hand one client another's answer. They are materially\n * different bodies, not variations in presentation.\n */\nexport const VARY_ON_RSC = [\n HEADER.rsc,\n HEADER.segments,\n HEADER.revalidate,\n HEADER.intercept,\n HEADER.referer,\n].join(', ')\n\n/**\n * What a response says about being stored, when it must not be.\n *\n * `Vary` alone is not enough on the platform this targets. Cloudflare ignores\n * `Vary` for everything except `Accept-Encoding`, and this package ships a\n * Workers story as a headline feature — so one url answering with a document,\n * a Flight payload, a segment and an interceptor, keyed only on the url, hands\n * one visitor another's body. Anything narrowed by a request header, and\n * anything behind a guard, says outright that it is not to be stored.\n */\nexport const PER_CLIENT = 'private, no-store'\n\n/**\n * And what a frozen, unguarded page says.\n *\n * Explicit rather than absent: with no directive at all, whether an\n * intermediary stores it comes down to that intermediary's heuristics, which\n * is not a decision this package should be leaving to chance in either\n * direction.\n */\nexport const REVALIDATE = 'public, max-age=0, must-revalidate'\n\n/**\n * And what a page rendered for one request says.\n *\n * It may have read the visitor's cookies, and nothing in the headers - which\n * leave before the render has finished - can know whether it did. So a shared\n * cache may not keep it; the visitor's own browser may, and must ask again.\n */\nexport const PER_REQUEST = 'private, max-age=0, must-revalidate'\n\n/**\n * The PPR contract: one marker, one shell endpoint, one resume, for any edge\n * and any framework that implements it. Vendor-neutral names on purpose: an\n * edge speaks the contract, and there is no second way in that is rsc-kit's\n * own. docs/hosts/deployment.mdx has the whole of it.\n */\nexport const PPR = {\n /** Reserved: nothing under it is the app's. */\n prefix: '/_ppr/',\n shellPath: '/_ppr/shell',\n resumePath: '/_ppr/resume',\n /** The platform's per-release token. Unset, both endpoints are a 404. */\n tokenEnv: 'PPR_TOKEN',\n token: 'PPR-Token',\n /** The build a shell or holes belong to - the marker's `build`. */\n build: 'PPR-Build',\n} as const\n"]}
|
package/dist/host.js
CHANGED
|
@@ -68,7 +68,7 @@ import { inlineFlight, inlineFlightStart } from "./inlineFlight.js";
|
|
|
68
68
|
import { currentNotFound, withRedirect } from "./redirect.js";
|
|
69
69
|
import { compressed } from "./compress.js";
|
|
70
70
|
import { withCache } from "./cache.js";
|
|
71
|
-
import { takeAfterWork, withRequest, withResponseDraft } from "./request.js";
|
|
71
|
+
import { takeAfterWork, withRequest, withResponseDraft, withUrl } from "./request.js";
|
|
72
72
|
import { criticalAssetsOf, linkHeader, mergeAssets } from "./earlyHints.js";
|
|
73
73
|
/**
|
|
74
74
|
* @internal For a host adapter that embeds the engine. An app imports this
|
|
@@ -81,7 +81,7 @@ export { redirect } from "./redirect.js";
|
|
|
81
81
|
// public surface so a host imports from one place.
|
|
82
82
|
/** @internal For a host adapter. An app never matches its own routes. */
|
|
83
83
|
export { matchIntercept, matchRoute, sharedDepth } from "./routing.js";
|
|
84
|
-
import { FLIGHT_TYPE, HEADER, HTML_TYPE, PER_CLIENT, PER_REQUEST, REVALIDATE, VARY_ON_RSC, } from "./headers.js";
|
|
84
|
+
import { FLIGHT_TYPE, HEADER, HTML_TYPE, PER_CLIENT, PER_REQUEST, REVALIDATE, PPR, VARY_ON_RSC, } from "./headers.js";
|
|
85
85
|
import { ServerAuthenticationError, ServerAuthorizationError, } from "./js/errors.js";
|
|
86
86
|
import { assertSigningSecret, serveChanges } from "./changed.js";
|
|
87
87
|
/**
|
|
@@ -433,6 +433,8 @@ export function createRscHandler(options) {
|
|
|
433
433
|
// own. Resolved on the first request: the engine reads it from a build
|
|
434
434
|
// product it only has at runtime.
|
|
435
435
|
let version = engine.buildId ? undefined : options.version;
|
|
436
|
+
// The PPR contract's build, read once - see pprBuild.
|
|
437
|
+
let pprBuildRead = null;
|
|
436
438
|
const maxActionBody = options.maxActionBody ?? DEFAULT_MAX_ACTION_BODY;
|
|
437
439
|
const compress = options.compress ?? true;
|
|
438
440
|
// Annotated rather than inferred: the narrowing below is lost inside the
|
|
@@ -681,7 +683,12 @@ export function createRscHandler(options) {
|
|
|
681
683
|
// Last, over the finished answer, headers and all. A stored answer
|
|
682
684
|
// is the same bytes for everyone and is compressed once, keyed by
|
|
683
685
|
// the build and the url it was stored for.
|
|
684
|
-
|
|
686
|
+
//
|
|
687
|
+
// Never the contract's resume: the edge appends it to the shell's
|
|
688
|
+
// plain bytes and compresses the page once, so gzip here would be
|
|
689
|
+
// garbage in the middle of a document. The edge drops
|
|
690
|
+
// Accept-Encoding, but this does not rely on it.
|
|
691
|
+
if (compress && !new URL(request.url).pathname.startsWith(PPR.prefix)) {
|
|
685
692
|
const answer = await compressed(request, response, servedFrom.get(response) === "stored" ? storedKey(request, response) : undefined);
|
|
686
693
|
const from = servedFrom.get(response);
|
|
687
694
|
if (answer !== response && from)
|
|
@@ -800,15 +807,10 @@ export function createRscHandler(options) {
|
|
|
800
807
|
if (url.pathname === HEADER.changesPath) {
|
|
801
808
|
return await serveChanges(request);
|
|
802
809
|
}
|
|
803
|
-
// The
|
|
804
|
-
//
|
|
805
|
-
if (
|
|
806
|
-
return await
|
|
807
|
-
}
|
|
808
|
-
if (request.method === "POST" &&
|
|
809
|
-
options.prerendered &&
|
|
810
|
-
url.pathname === HEADER.pprResumePath) {
|
|
811
|
-
return await servePprResume(request, url, options.prerendered);
|
|
810
|
+
// The PPR contract, for a platform's edge. The whole prefix is reserved,
|
|
811
|
+
// and is a 404 to anyone without the release's token.
|
|
812
|
+
if (url.pathname.startsWith(PPR.prefix)) {
|
|
813
|
+
return await servePprContract(request, url);
|
|
812
814
|
}
|
|
813
815
|
// A form submitted before the page had a runtime: the browser posts it
|
|
814
816
|
// to the page's own url, as React wrote it, with the action's id among
|
|
@@ -1117,8 +1119,12 @@ export function createRscHandler(options) {
|
|
|
1117
1119
|
}
|
|
1118
1120
|
/** Whether a health probe sent this deployment's host-call secret, compared in constant time. */
|
|
1119
1121
|
function probeHoldsSecret(request) {
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
+
return holdsSecret(request, "x-rsc-host-secret", "RSC_HOST_CALL_SECRET");
|
|
1123
|
+
}
|
|
1124
|
+
/** Whether the request's header holds the secret in the environment's variable, compared in constant time. */
|
|
1125
|
+
function holdsSecret(request, header, variable) {
|
|
1126
|
+
const expected = (typeof process === "undefined" ? undefined : process.env[variable]) ?? "";
|
|
1127
|
+
const given = request.headers.get(header) ?? "";
|
|
1122
1128
|
// An unset secret matches nothing, the empty string included.
|
|
1123
1129
|
if (expected === "" || given.length !== expected.length)
|
|
1124
1130
|
return false;
|
|
@@ -1173,44 +1179,26 @@ export function createRscHandler(options) {
|
|
|
1173
1179
|
storedAs.set(answer, storedName);
|
|
1174
1180
|
return answer;
|
|
1175
1181
|
}
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1182
|
+
/**
|
|
1183
|
+
* A page's shell, by the rule both shell endpoints apply: a PPR page, on a
|
|
1184
|
+
* route with no guard, whose shell the build wrote for this url or for its
|
|
1185
|
+
* pattern. Anything else is a 404 - the same pages edgeShells leaves out of
|
|
1186
|
+
* the marker, so the list and the origin cannot disagree.
|
|
1187
|
+
*/
|
|
1188
|
+
async function shellAt(pathname, read) {
|
|
1183
1189
|
// Matched from the url asked for, never from anything else the caller sent.
|
|
1184
|
-
const route = matchRoute(routes,
|
|
1190
|
+
const route = matchRoute(routes, pathname);
|
|
1185
1191
|
if (!route)
|
|
1186
1192
|
return new Response("No such page", { status: 404 });
|
|
1187
1193
|
if (isGuarded(route)) {
|
|
1188
1194
|
return new Response("This route is not edge-cacheable", { status: 404 });
|
|
1189
1195
|
}
|
|
1190
|
-
const key = storedKeyFor(
|
|
1196
|
+
const key = storedKeyFor(pathname, route);
|
|
1191
1197
|
if (key === null)
|
|
1192
1198
|
return new Response("No such page", { status: 404 });
|
|
1193
1199
|
const shell = (await read(`${key}.ppr.html`)) ??
|
|
1194
1200
|
(await read(`${patternKey(route.route)}.ppr.html`));
|
|
1195
|
-
|
|
1196
|
-
return new Response("No shell for this page", { status: 404 });
|
|
1197
|
-
return new Response(JSON.stringify({ shell, version: version ?? null }), {
|
|
1198
|
-
headers: withVersion({
|
|
1199
|
-
"Content-Type": "application/json",
|
|
1200
|
-
// Deliberately NOT the REVALIDATE the rest of the host sends. That is
|
|
1201
|
-
// `max-age=0, must-revalidate`, which a cache honours by treating the
|
|
1202
|
-
// entry as stale the moment it arrives — an edge would store this and
|
|
1203
|
-
// never once serve it, so the whole thing would quietly do nothing
|
|
1204
|
-
// while looking like it worked, because the miss path serves correct
|
|
1205
|
-
// pages.
|
|
1206
|
-
//
|
|
1207
|
-
// This is build output with nothing of the caller in it, and a guarded
|
|
1208
|
-
// route never reaches here at all, so it is genuinely cacheable. An
|
|
1209
|
-
// hour bounds how long a deploy can be served against a stale shell;
|
|
1210
|
-
// the version below catches it sooner than that.
|
|
1211
|
-
"Cache-Control": "public, max-age=3600",
|
|
1212
|
-
}),
|
|
1213
|
-
});
|
|
1201
|
+
return shell ?? new Response("No shell for this page", { status: 404 });
|
|
1214
1202
|
}
|
|
1215
1203
|
/**
|
|
1216
1204
|
* Finish a shell for the visitor this request belongs to.
|
|
@@ -1225,53 +1213,142 @@ export function createRscHandler(options) {
|
|
|
1225
1213
|
* of its own. A resume asked for with no cookies is an anonymous visitor and
|
|
1226
1214
|
* gets an anonymous answer.
|
|
1227
1215
|
*/
|
|
1228
|
-
async function
|
|
1229
|
-
const
|
|
1230
|
-
if (!target || !target.startsWith("/")) {
|
|
1231
|
-
return new Response("A url is required", { status: 400 });
|
|
1232
|
-
}
|
|
1233
|
-
const pathname = new URL(target, url.origin).pathname;
|
|
1216
|
+
async function resumeAt(request, target, read) {
|
|
1217
|
+
const pathname = target.pathname;
|
|
1234
1218
|
const route = matchRoute(routes, pathname);
|
|
1235
1219
|
if (!route)
|
|
1236
1220
|
return new Response("No such page", { status: 404 });
|
|
1237
|
-
// The
|
|
1238
|
-
//
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
shellKey =
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1221
|
+
// The holes render for the visitor's url, not for this endpoint's: a
|
|
1222
|
+
// component reading the search params would otherwise find `?url=…`.
|
|
1223
|
+
// Absolute, as the request's own url is: searchParams() parses it whole.
|
|
1224
|
+
return await withUrl(target.href, async () => {
|
|
1225
|
+
// The same refusal the document would get, before a byte of the holes
|
|
1226
|
+
// is rendered. Nothing below runs for a caller this turns away.
|
|
1227
|
+
const refusal = await refuseUnlessAllowed(request, route);
|
|
1228
|
+
if (refusal === RENDER_NOT_FOUND)
|
|
1229
|
+
return new Response("Not found", { status: 404 });
|
|
1230
|
+
if (refusal)
|
|
1231
|
+
return refusal;
|
|
1232
|
+
if (!engine.handleRscResume) {
|
|
1233
|
+
return new Response("This engine cannot resume", { status: 500 });
|
|
1234
|
+
}
|
|
1235
|
+
const key = storedKeyFor(pathname, route);
|
|
1236
|
+
if (key === null)
|
|
1237
|
+
return new Response("No such page", { status: 404 });
|
|
1238
|
+
const pattern = patternKey(route.route);
|
|
1239
|
+
let shellKey = null;
|
|
1240
|
+
if ((await read(`${key}.ppr.html`)) !== null)
|
|
1241
|
+
shellKey = key;
|
|
1242
|
+
else if ((await read(`${pattern}.ppr.html`)) !== null)
|
|
1243
|
+
shellKey = pattern;
|
|
1244
|
+
// Read from our own artifacts. Never from the request body — that is
|
|
1245
|
+
// the difference between this and the protocol it is modelled on.
|
|
1246
|
+
const state = shellKey === null ? null : await read(`${shellKey}.postponed.json`);
|
|
1247
|
+
if (state === null)
|
|
1248
|
+
return new Response("Nothing to resume", { status: 404 });
|
|
1249
|
+
const { htmlStream } = await engine.handleRscResume(route.route.component, route.params, route.route.layouts.map((component) => ({ component, props: {} })), route.route.loadings, route.route.slots, {}, JSON.parse(state), undefined, shellKey === key ? pathname : "", pathname);
|
|
1250
|
+
// Carries the build version so a caller holding a cached shell can tell
|
|
1251
|
+
// that it no longer belongs to this origin. A shell from an older build
|
|
1252
|
+
// does not make the resume fail — it replays against slots that have
|
|
1253
|
+
// moved and React falls back to client rendering, silently and for
|
|
1254
|
+
// good. Nothing else would ever report it.
|
|
1255
|
+
return new Response(htmlStream, {
|
|
1256
|
+
headers: withVersion({
|
|
1257
|
+
"Content-Type": HTML_TYPE,
|
|
1258
|
+
// Rendered for whoever asked. Never cacheable.
|
|
1259
|
+
"Cache-Control": PER_CLIENT,
|
|
1260
|
+
...(pprBuildRead ? { [PPR.build]: pprBuildRead } : {}),
|
|
1261
|
+
}),
|
|
1262
|
+
});
|
|
1273
1263
|
});
|
|
1274
1264
|
}
|
|
1265
|
+
/**
|
|
1266
|
+
* The PPR contract's two endpoints, for a platform's edge. They exist only
|
|
1267
|
+
* for a caller holding the PPR_TOKEN the platform set: unset, missing or wrong,
|
|
1268
|
+
* everything under /_ppr/ is a 404, as if there were nothing here - so an
|
|
1269
|
+
* app is safe wherever it runs, and only the platform can ask.
|
|
1270
|
+
*
|
|
1271
|
+
* The shell goes as JSON with an empty `state`: the resume reads its own
|
|
1272
|
+
* from the build and ignores the body. Both answers name the build in
|
|
1273
|
+
* PPR-Build.
|
|
1274
|
+
*/
|
|
1275
|
+
async function servePprContract(request, url) {
|
|
1276
|
+
const answer = await pprContractAnswer(request, url);
|
|
1277
|
+
// Every answer here depends on the token, a 404 included: stored by a
|
|
1278
|
+
// cache in between, one would be served to callers with the wrong token
|
|
1279
|
+
// or none - and a stored 404 would hide a real shell from the edge.
|
|
1280
|
+
try {
|
|
1281
|
+
answer.headers.set("Cache-Control", "private, no-store");
|
|
1282
|
+
return answer;
|
|
1283
|
+
}
|
|
1284
|
+
catch {
|
|
1285
|
+
return new Response(answer.body, {
|
|
1286
|
+
status: answer.status,
|
|
1287
|
+
headers: { ...Object.fromEntries(answer.headers), "Cache-Control": "private, no-store" },
|
|
1288
|
+
});
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
async function pprContractAnswer(request, url) {
|
|
1292
|
+
const missing = new Response("Not found", { status: 404 });
|
|
1293
|
+
const read = options.prerendered;
|
|
1294
|
+
if (!read || !holdsSecret(request, PPR.token, PPR.tokenEnv))
|
|
1295
|
+
return missing;
|
|
1296
|
+
const build = await pprBuild(read);
|
|
1297
|
+
if (!build)
|
|
1298
|
+
return missing;
|
|
1299
|
+
if (url.pathname === PPR.shellPath && (request.method === "GET" || request.method === "HEAD")) {
|
|
1300
|
+
const pathname = contractPathname(url.searchParams.get("path"), url);
|
|
1301
|
+
if (pathname === null)
|
|
1302
|
+
return missing;
|
|
1303
|
+
const shell = await shellAt(pathname, read);
|
|
1304
|
+
if (shell instanceof Response)
|
|
1305
|
+
return shell;
|
|
1306
|
+
return new Response(request.method === "HEAD" ? null : JSON.stringify({ v: 1, build, html: shell, state: "" }), {
|
|
1307
|
+
headers: {
|
|
1308
|
+
"Content-Type": "application/json",
|
|
1309
|
+
// Fixed for the build, so no ETag. And never stored on the way:
|
|
1310
|
+
// it was answered only for the token, and a cache between the
|
|
1311
|
+
// edge and here would hand it to callers without one.
|
|
1312
|
+
"Cache-Control": "private, no-store",
|
|
1313
|
+
[PPR.build]: build,
|
|
1314
|
+
},
|
|
1315
|
+
});
|
|
1316
|
+
}
|
|
1317
|
+
if (url.pathname === PPR.resumePath && request.method === "POST") {
|
|
1318
|
+
const raw = url.searchParams.get("url");
|
|
1319
|
+
const at = raw === null ? -1 : raw.indexOf("?");
|
|
1320
|
+
const pathname = contractPathname(at === -1 ? raw : raw.slice(0, at), url);
|
|
1321
|
+
if (pathname === null)
|
|
1322
|
+
return missing;
|
|
1323
|
+
return await resumeAt(request, new URL(pathname + (at === -1 ? "" : raw.slice(at)), url.origin), read);
|
|
1324
|
+
}
|
|
1325
|
+
return missing;
|
|
1326
|
+
}
|
|
1327
|
+
/**
|
|
1328
|
+
* The build the marker names, which the build wrote beside the shells - not
|
|
1329
|
+
* the client's id this host sends a browser, which a server-only change
|
|
1330
|
+
* keeps. See pprBuildId.
|
|
1331
|
+
*/
|
|
1332
|
+
async function pprBuild(read) {
|
|
1333
|
+
pprBuildRead ??= (await read("_ppr.build"))?.trim() || null;
|
|
1334
|
+
return pprBuildRead;
|
|
1335
|
+
}
|
|
1336
|
+
/**
|
|
1337
|
+
* A contract path - absolute and decoded once - as the pathname this host
|
|
1338
|
+
* matches, or null for one that would not survive the trip unchanged: a
|
|
1339
|
+
* dot segment, a query or fragment inside it, an encoded slash.
|
|
1340
|
+
*/
|
|
1341
|
+
function contractPathname(path, base) {
|
|
1342
|
+
if (!path || !path.startsWith("/"))
|
|
1343
|
+
return null;
|
|
1344
|
+
const pathname = new URL(path, base.origin).pathname;
|
|
1345
|
+
try {
|
|
1346
|
+
return decodeURIComponent(pathname) === path ? pathname : null;
|
|
1347
|
+
}
|
|
1348
|
+
catch {
|
|
1349
|
+
return null;
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1275
1352
|
async function servePrerendered(request, url, route, source) {
|
|
1276
1353
|
const key = storedKeyFor(url.pathname, route);
|
|
1277
1354
|
if (key === null)
|