@rsc-kit/core 0.30.26 → 0.30.28
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/action.d.ts +11 -0
- package/dist/action.js +20 -1
- package/dist/action.js.map +1 -1
- 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 +147 -84
- package/dist/host.js.map +1 -1
- package/dist/vite.js +32 -4
- package/dist/vite.js.map +1 -1
- package/package.json +1 -1
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
|
/**
|
|
@@ -681,7 +681,12 @@ export function createRscHandler(options) {
|
|
|
681
681
|
// Last, over the finished answer, headers and all. A stored answer
|
|
682
682
|
// is the same bytes for everyone and is compressed once, keyed by
|
|
683
683
|
// the build and the url it was stored for.
|
|
684
|
-
|
|
684
|
+
//
|
|
685
|
+
// Never the contract's resume: the edge appends it to the shell's
|
|
686
|
+
// plain bytes and compresses the page once, so gzip here would be
|
|
687
|
+
// garbage in the middle of a document. The edge drops
|
|
688
|
+
// Accept-Encoding, but this does not rely on it.
|
|
689
|
+
if (compress && !new URL(request.url).pathname.startsWith(PPR.prefix)) {
|
|
685
690
|
const answer = await compressed(request, response, servedFrom.get(response) === "stored" ? storedKey(request, response) : undefined);
|
|
686
691
|
const from = servedFrom.get(response);
|
|
687
692
|
if (answer !== response && from)
|
|
@@ -800,15 +805,10 @@ export function createRscHandler(options) {
|
|
|
800
805
|
if (url.pathname === HEADER.changesPath) {
|
|
801
806
|
return await serveChanges(request);
|
|
802
807
|
}
|
|
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);
|
|
808
|
+
// The PPR contract, for a platform's edge. The whole prefix is reserved,
|
|
809
|
+
// and is a 404 to anyone without the release's token.
|
|
810
|
+
if (url.pathname.startsWith(PPR.prefix)) {
|
|
811
|
+
return await servePprContract(request, url);
|
|
812
812
|
}
|
|
813
813
|
// A form submitted before the page had a runtime: the browser posts it
|
|
814
814
|
// to the page's own url, as React wrote it, with the action's id among
|
|
@@ -1117,8 +1117,12 @@ export function createRscHandler(options) {
|
|
|
1117
1117
|
}
|
|
1118
1118
|
/** Whether a health probe sent this deployment's host-call secret, compared in constant time. */
|
|
1119
1119
|
function probeHoldsSecret(request) {
|
|
1120
|
-
|
|
1121
|
-
|
|
1120
|
+
return holdsSecret(request, "x-rsc-host-secret", "RSC_HOST_CALL_SECRET");
|
|
1121
|
+
}
|
|
1122
|
+
/** Whether the request's header holds the secret in the environment's variable, compared in constant time. */
|
|
1123
|
+
function holdsSecret(request, header, variable) {
|
|
1124
|
+
const expected = (typeof process === "undefined" ? undefined : process.env[variable]) ?? "";
|
|
1125
|
+
const given = request.headers.get(header) ?? "";
|
|
1122
1126
|
// An unset secret matches nothing, the empty string included.
|
|
1123
1127
|
if (expected === "" || given.length !== expected.length)
|
|
1124
1128
|
return false;
|
|
@@ -1173,44 +1177,26 @@ export function createRscHandler(options) {
|
|
|
1173
1177
|
storedAs.set(answer, storedName);
|
|
1174
1178
|
return answer;
|
|
1175
1179
|
}
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1180
|
+
/**
|
|
1181
|
+
* A page's shell, by the rule both shell endpoints apply: a PPR page, on a
|
|
1182
|
+
* route with no guard, whose shell the build wrote for this url or for its
|
|
1183
|
+
* pattern. Anything else is a 404 - the same pages edgeShells leaves out of
|
|
1184
|
+
* the marker, so the list and the origin cannot disagree.
|
|
1185
|
+
*/
|
|
1186
|
+
async function shellAt(pathname, read) {
|
|
1183
1187
|
// Matched from the url asked for, never from anything else the caller sent.
|
|
1184
|
-
const route = matchRoute(routes,
|
|
1188
|
+
const route = matchRoute(routes, pathname);
|
|
1185
1189
|
if (!route)
|
|
1186
1190
|
return new Response("No such page", { status: 404 });
|
|
1187
1191
|
if (isGuarded(route)) {
|
|
1188
1192
|
return new Response("This route is not edge-cacheable", { status: 404 });
|
|
1189
1193
|
}
|
|
1190
|
-
const key = storedKeyFor(
|
|
1194
|
+
const key = storedKeyFor(pathname, route);
|
|
1191
1195
|
if (key === null)
|
|
1192
1196
|
return new Response("No such page", { status: 404 });
|
|
1193
1197
|
const shell = (await read(`${key}.ppr.html`)) ??
|
|
1194
1198
|
(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
|
-
});
|
|
1199
|
+
return shell ?? new Response("No shell for this page", { status: 404 });
|
|
1214
1200
|
}
|
|
1215
1201
|
/**
|
|
1216
1202
|
* Finish a shell for the visitor this request belongs to.
|
|
@@ -1225,53 +1211,130 @@ export function createRscHandler(options) {
|
|
|
1225
1211
|
* of its own. A resume asked for with no cookies is an anonymous visitor and
|
|
1226
1212
|
* gets an anonymous answer.
|
|
1227
1213
|
*/
|
|
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;
|
|
1214
|
+
async function resumeAt(request, target, read) {
|
|
1215
|
+
const pathname = target.pathname;
|
|
1234
1216
|
const route = matchRoute(routes, pathname);
|
|
1235
1217
|
if (!route)
|
|
1236
1218
|
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
|
-
|
|
1219
|
+
// The holes render for the visitor's url, not for this endpoint's: a
|
|
1220
|
+
// component reading the search params would otherwise find `?url=…`.
|
|
1221
|
+
// Absolute, as the request's own url is: searchParams() parses it whole.
|
|
1222
|
+
return await withUrl(target.href, async () => {
|
|
1223
|
+
// The same refusal the document would get, before a byte of the holes
|
|
1224
|
+
// is rendered. Nothing below runs for a caller this turns away.
|
|
1225
|
+
const refusal = await refuseUnlessAllowed(request, route);
|
|
1226
|
+
if (refusal === RENDER_NOT_FOUND)
|
|
1227
|
+
return new Response("Not found", { status: 404 });
|
|
1228
|
+
if (refusal)
|
|
1229
|
+
return refusal;
|
|
1230
|
+
if (!engine.handleRscResume) {
|
|
1231
|
+
return new Response("This engine cannot resume", { status: 500 });
|
|
1232
|
+
}
|
|
1233
|
+
const key = storedKeyFor(pathname, route);
|
|
1234
|
+
if (key === null)
|
|
1235
|
+
return new Response("No such page", { status: 404 });
|
|
1236
|
+
const pattern = patternKey(route.route);
|
|
1237
|
+
let shellKey = null;
|
|
1238
|
+
if ((await read(`${key}.ppr.html`)) !== null)
|
|
1239
|
+
shellKey = key;
|
|
1240
|
+
else if ((await read(`${pattern}.ppr.html`)) !== null)
|
|
1241
|
+
shellKey = pattern;
|
|
1242
|
+
// Read from our own artifacts. Never from the request body — that is
|
|
1243
|
+
// the difference between this and the protocol it is modelled on.
|
|
1244
|
+
const state = shellKey === null ? null : await read(`${shellKey}.postponed.json`);
|
|
1245
|
+
if (state === null)
|
|
1246
|
+
return new Response("Nothing to resume", { status: 404 });
|
|
1247
|
+
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);
|
|
1248
|
+
// Carries the build version so a caller holding a cached shell can tell
|
|
1249
|
+
// that it no longer belongs to this origin. A shell from an older build
|
|
1250
|
+
// does not make the resume fail — it replays against slots that have
|
|
1251
|
+
// moved and React falls back to client rendering, silently and for
|
|
1252
|
+
// good. Nothing else would ever report it.
|
|
1253
|
+
return new Response(htmlStream, {
|
|
1254
|
+
headers: withVersion({
|
|
1255
|
+
"Content-Type": HTML_TYPE,
|
|
1256
|
+
// Rendered for whoever asked. Never cacheable.
|
|
1257
|
+
"Cache-Control": PER_CLIENT,
|
|
1258
|
+
...(version ? { [PPR.build]: version } : {}),
|
|
1259
|
+
}),
|
|
1260
|
+
});
|
|
1273
1261
|
});
|
|
1274
1262
|
}
|
|
1263
|
+
/**
|
|
1264
|
+
* The PPR contract's two endpoints, for a platform's edge. They exist only
|
|
1265
|
+
* for a caller holding the release's PPR_TOKEN: unset, missing or wrong,
|
|
1266
|
+
* everything under /_ppr/ is a 404, as if there were nothing here - so an
|
|
1267
|
+
* app is safe wherever it runs, and only the platform can ask.
|
|
1268
|
+
*
|
|
1269
|
+
* The shell goes as JSON with an empty `state`: the resume reads its own
|
|
1270
|
+
* from the build and ignores the body. Both answers name the build in
|
|
1271
|
+
* PPR-Build.
|
|
1272
|
+
*/
|
|
1273
|
+
async function servePprContract(request, url) {
|
|
1274
|
+
const answer = await pprContractAnswer(request, url);
|
|
1275
|
+
// Every answer here depends on the token, a 404 included: stored by a
|
|
1276
|
+
// cache in between, one would be served to callers with the wrong token
|
|
1277
|
+
// or none - and a stored 404 would hide a real shell from the edge.
|
|
1278
|
+
try {
|
|
1279
|
+
answer.headers.set("Cache-Control", "private, no-store");
|
|
1280
|
+
return answer;
|
|
1281
|
+
}
|
|
1282
|
+
catch {
|
|
1283
|
+
return new Response(answer.body, {
|
|
1284
|
+
status: answer.status,
|
|
1285
|
+
headers: { ...Object.fromEntries(answer.headers), "Cache-Control": "private, no-store" },
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
async function pprContractAnswer(request, url) {
|
|
1290
|
+
const missing = new Response("Not found", { status: 404 });
|
|
1291
|
+
const read = options.prerendered;
|
|
1292
|
+
if (!read || !version || !holdsSecret(request, PPR.token, PPR.tokenEnv))
|
|
1293
|
+
return missing;
|
|
1294
|
+
if (url.pathname === PPR.shellPath && (request.method === "GET" || request.method === "HEAD")) {
|
|
1295
|
+
const pathname = contractPathname(url.searchParams.get("path"), url);
|
|
1296
|
+
if (pathname === null)
|
|
1297
|
+
return missing;
|
|
1298
|
+
const shell = await shellAt(pathname, read);
|
|
1299
|
+
if (shell instanceof Response)
|
|
1300
|
+
return shell;
|
|
1301
|
+
return new Response(request.method === "HEAD" ? null : JSON.stringify({ v: 1, build: version, html: shell, state: "" }), {
|
|
1302
|
+
headers: {
|
|
1303
|
+
"Content-Type": "application/json",
|
|
1304
|
+
// Fixed for the build, so no ETag. And never stored on the way:
|
|
1305
|
+
// it was answered only for the token, and a cache between the
|
|
1306
|
+
// edge and here would hand it to callers without one.
|
|
1307
|
+
"Cache-Control": "private, no-store",
|
|
1308
|
+
[PPR.build]: version,
|
|
1309
|
+
},
|
|
1310
|
+
});
|
|
1311
|
+
}
|
|
1312
|
+
if (url.pathname === PPR.resumePath && request.method === "POST") {
|
|
1313
|
+
const raw = url.searchParams.get("url");
|
|
1314
|
+
const at = raw === null ? -1 : raw.indexOf("?");
|
|
1315
|
+
const pathname = contractPathname(at === -1 ? raw : raw.slice(0, at), url);
|
|
1316
|
+
if (pathname === null)
|
|
1317
|
+
return missing;
|
|
1318
|
+
return await resumeAt(request, new URL(pathname + (at === -1 ? "" : raw.slice(at)), url.origin), read);
|
|
1319
|
+
}
|
|
1320
|
+
return missing;
|
|
1321
|
+
}
|
|
1322
|
+
/**
|
|
1323
|
+
* A contract path - absolute and decoded once - as the pathname this host
|
|
1324
|
+
* matches, or null for one that would not survive the trip unchanged: a
|
|
1325
|
+
* dot segment, a query or fragment inside it, an encoded slash.
|
|
1326
|
+
*/
|
|
1327
|
+
function contractPathname(path, base) {
|
|
1328
|
+
if (!path || !path.startsWith("/"))
|
|
1329
|
+
return null;
|
|
1330
|
+
const pathname = new URL(path, base.origin).pathname;
|
|
1331
|
+
try {
|
|
1332
|
+
return decodeURIComponent(pathname) === path ? pathname : null;
|
|
1333
|
+
}
|
|
1334
|
+
catch {
|
|
1335
|
+
return null;
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1275
1338
|
async function servePrerendered(request, url, route, source) {
|
|
1276
1339
|
const key = storedKeyFor(url.pathname, route);
|
|
1277
1340
|
if (key === null)
|