@rsc-kit/core 0.22.9 → 0.24.0
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/host.d.ts +2 -0
- package/dist/host.js +31 -9
- package/dist/host.js.map +1 -1
- package/dist/hostCalls.js +8 -0
- package/dist/hostCalls.js.map +1 -1
- package/dist/js/Form.d.ts +13 -0
- package/dist/js/Form.js +5 -0
- package/dist/js/Form.js.map +1 -1
- package/dist/js/navigate.js +5 -6
- package/dist/js/navigate.js.map +1 -1
- package/dist/prerender.js +15 -1
- package/dist/prerender.js.map +1 -1
- package/dist/request.js +2 -0
- package/dist/request.js.map +1 -1
- package/dist/routing.d.ts +18 -0
- package/dist/routing.js +35 -0
- package/dist/routing.js.map +1 -1
- package/dist/testHost.d.ts +53 -0
- package/dist/testHost.js +76 -0
- package/dist/testHost.js.map +1 -0
- package/dist/testing.d.ts +15 -0
- package/dist/testing.js +17 -0
- package/dist/testing.js.map +1 -1
- package/dist/typegen.js +0 -0
- package/dist/vite.d.ts +16 -0
- package/dist/vite.js +360 -53
- package/dist/vite.js.map +1 -1
- package/package.json +2 -2
package/dist/vite.js
CHANGED
|
@@ -199,6 +199,19 @@ let foundAssets = {
|
|
|
199
199
|
};
|
|
200
200
|
/** Host functions to generate stubs for — see RscKitOptions.hostActions. */
|
|
201
201
|
let hostActions;
|
|
202
|
+
/** What rpc() may be called with, from rsc-host-functions.json; null when the backend lists none. */
|
|
203
|
+
let hostFunctions = null;
|
|
204
|
+
/** The backend's typed signatures, by function name, and the named types they refer to. */
|
|
205
|
+
let hostTypes = {};
|
|
206
|
+
let hostDefs = {};
|
|
207
|
+
/**
|
|
208
|
+
* The manifest commands run in this process, by project and command.
|
|
209
|
+
*
|
|
210
|
+
* Once, not on every config load: a dev server reloads its config when the
|
|
211
|
+
* manifest changes, and running the command again there rewrote the file it
|
|
212
|
+
* had just restarted for - a restart that never ended.
|
|
213
|
+
*/
|
|
214
|
+
const hostManifestRan = new Set();
|
|
202
215
|
/**
|
|
203
216
|
* This file's directory.
|
|
204
217
|
*
|
|
@@ -230,47 +243,43 @@ function envRouteConfig() {
|
|
|
230
243
|
return { file, dynamicPattern: new RegExp(pattern) };
|
|
231
244
|
}
|
|
232
245
|
/** The file a backend writes its action names into. */
|
|
233
|
-
const
|
|
234
|
-
/**
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
*
|
|
243
|
-
* php artisan rsc:action-manifest > rsc-host-actions.json
|
|
244
|
-
* go run ./cmd/rsc-actions > rsc-host-actions.json
|
|
245
|
-
*
|
|
246
|
-
* Absent is not an error. An app with no host actions has no file, and one
|
|
247
|
-
* that has them regenerates it as part of its build.
|
|
248
|
-
*/
|
|
249
|
-
function fileHostActions(root) {
|
|
250
|
-
const path = join(root, HOST_ACTIONS_FILE);
|
|
251
|
-
if (!existsSync(path))
|
|
252
|
-
return {};
|
|
253
|
-
try {
|
|
254
|
-
const parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
255
|
-
// An empty list is an empty map. PHP's json_encode writes an empty
|
|
256
|
-
// array as [] whatever it was declared as, so a backend with no
|
|
257
|
-
// actions yet wrote exactly that - and the first `dev` of a fresh
|
|
258
|
-
// install refused to start over a file that said, correctly, "none".
|
|
259
|
-
if (Array.isArray(parsed) && parsed.length === 0)
|
|
260
|
-
return {};
|
|
261
|
-
if (parsed === null ||
|
|
262
|
-
typeof parsed !== "object" ||
|
|
263
|
-
Array.isArray(parsed)) {
|
|
264
|
-
throw new Error('expected an object of { jsName: "Class.method" }');
|
|
246
|
+
const HOST_FILE = "rsc-host.json";
|
|
247
|
+
/** The "use server" module of the backend's action stubs, written beside the app. */
|
|
248
|
+
const HOST_ACTIONS_MODULE = "server-actions.generated.ts";
|
|
249
|
+
function fileHostManifest(root) {
|
|
250
|
+
const path = join(root, HOST_FILE);
|
|
251
|
+
if (!existsSync(path)) {
|
|
252
|
+
if (existsSync(join(root, "rsc-host-actions.json"))) {
|
|
253
|
+
throw new Error("[rsc-kit] rsc-host-actions.json is the old manifest. The backend now writes " +
|
|
254
|
+
HOST_FILE + " - { actions, functions } - so update the adapter and run its manifest command.");
|
|
265
255
|
}
|
|
266
|
-
return
|
|
256
|
+
return { actions: {}, functions: null, types: {}, defs: {} };
|
|
257
|
+
}
|
|
258
|
+
let parsed;
|
|
259
|
+
try {
|
|
260
|
+
parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
267
261
|
}
|
|
268
262
|
catch (error) {
|
|
269
|
-
|
|
270
|
-
// server action then fails at build time, naming the import rather than
|
|
271
|
-
// this file.
|
|
272
|
-
throw new Error(`Could not read ${HOST_ACTIONS_FILE}: ${error instanceof Error ? error.message : String(error)}`);
|
|
263
|
+
throw new Error(`[rsc-kit] Could not read ${HOST_FILE}: ${error.message}`);
|
|
273
264
|
}
|
|
265
|
+
// An empty list is an empty map: PHP's json_encode writes an empty array
|
|
266
|
+
// as [] whatever it was declared as.
|
|
267
|
+
const actions = Array.isArray(parsed?.actions) && parsed.actions.length === 0 ? {} : parsed?.actions ?? {};
|
|
268
|
+
if (typeof actions !== "object" || actions === null || Array.isArray(actions)) {
|
|
269
|
+
throw new Error(`[rsc-kit] ${HOST_FILE}: "actions" must be an object of { jsName: "Function.name" }.`);
|
|
270
|
+
}
|
|
271
|
+
const functions = parsed?.functions;
|
|
272
|
+
if (functions !== undefined && (!Array.isArray(functions) || functions.some((n) => typeof n !== "string"))) {
|
|
273
|
+
throw new Error(`[rsc-kit] ${HOST_FILE}: "functions" must be an array of names.`);
|
|
274
|
+
}
|
|
275
|
+
const isMap = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
276
|
+
return {
|
|
277
|
+
types: isMap(parsed?.types) ? parsed.types : {},
|
|
278
|
+
defs: isMap(parsed?.defs) ? parsed.defs : {},
|
|
279
|
+
actions: actions,
|
|
280
|
+
// The reserved guard call is the engine's to make, not the app's.
|
|
281
|
+
functions: functions ? functions.filter((n) => !n.startsWith("__rsc.")).sort() : null,
|
|
282
|
+
};
|
|
274
283
|
}
|
|
275
284
|
/**
|
|
276
285
|
* The alias that lets a vendored copy of this package be imported by name.
|
|
@@ -402,7 +411,43 @@ function resolvePaths(options) {
|
|
|
402
411
|
screenshots: foundAssets.screenshots.map((shot) => shot.href),
|
|
403
412
|
};
|
|
404
413
|
}
|
|
405
|
-
|
|
414
|
+
loadHostManifest(options);
|
|
415
|
+
}
|
|
416
|
+
function loadHostManifest(options) {
|
|
417
|
+
const manifest = fileHostManifest(projectRoot);
|
|
418
|
+
hostActions = options.hostActions ?? manifest.actions;
|
|
419
|
+
hostFunctions = manifest.functions;
|
|
420
|
+
hostTypes = manifest.types;
|
|
421
|
+
hostDefs = manifest.defs;
|
|
422
|
+
}
|
|
423
|
+
/** Run the backend's manifest command, once per process; see the option. */
|
|
424
|
+
function runHostManifest(options, isBuild) {
|
|
425
|
+
const spec = options.hostManifest;
|
|
426
|
+
if (!spec)
|
|
427
|
+
return;
|
|
428
|
+
const ran = projectRoot + "\0" + spec.command.join("\0");
|
|
429
|
+
if (hostManifestRan.has(ran))
|
|
430
|
+
return;
|
|
431
|
+
hostManifestRan.add(ran);
|
|
432
|
+
const [command, ...args] = spec.command;
|
|
433
|
+
if (!command)
|
|
434
|
+
throw new Error("[rsc-kit] hostManifest.command is empty.");
|
|
435
|
+
const run = spawnSync(command, args, {
|
|
436
|
+
cwd: spec.cwd ? resolve(projectRoot, spec.cwd) : projectRoot,
|
|
437
|
+
stdio: ["ignore", "inherit", "pipe"],
|
|
438
|
+
encoding: "utf-8",
|
|
439
|
+
});
|
|
440
|
+
if (run.status === 0) {
|
|
441
|
+
loadHostManifest(options);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
const message = `the backend's manifest command failed (${spec.command.join(" ")}):\n` +
|
|
445
|
+
(run.error?.message ?? run.stderr ?? "").trim();
|
|
446
|
+
// A build from a stale manifest ships stubs for functions the backend no
|
|
447
|
+
// longer has; dev carries on with the one on disk, and says so.
|
|
448
|
+
if (isBuild)
|
|
449
|
+
throw new Error(`[rsc-kit] ${message}`);
|
|
450
|
+
log(`${message}\n Using the ${HOST_FILE} already on disk.`);
|
|
406
451
|
}
|
|
407
452
|
function log(...args) {
|
|
408
453
|
console.error("[rsc-kit]", ...args);
|
|
@@ -731,7 +776,7 @@ function writeHostBindings(manifest) {
|
|
|
731
776
|
// which typechecks a server and fails a prerender script.
|
|
732
777
|
writeGenerated(join(typesDir, "rsc-engine.d.ts"), ENGINE_TYPES);
|
|
733
778
|
warnIfTypesUnreachable();
|
|
734
|
-
const target = join(sourceDir,
|
|
779
|
+
const target = join(sourceDir, HOST_ACTIONS_MODULE);
|
|
735
780
|
// A host with no functions of its own leaves no file behind: kept, its
|
|
736
781
|
// stubs would go on naming targets the host has stopped answering for.
|
|
737
782
|
if (Object.keys(hostActions).length === 0) {
|
|
@@ -1674,7 +1719,11 @@ async function prerenderAfterBundles(bundle, staticDir, assetsDir, knownActions
|
|
|
1674
1719
|
// an action nothing else in the app states — whether anything checks who
|
|
1675
1720
|
// calls it.
|
|
1676
1721
|
const audited = await auditActions(engine, knownActions);
|
|
1677
|
-
|
|
1722
|
+
// Not the host's stubs. Each forwards to a backend function, and the check
|
|
1723
|
+
// on who calls it runs there - Laravel's guards, Go's middleware - where
|
|
1724
|
+
// this build cannot see it. Counted, they made every backend action look
|
|
1725
|
+
// unguarded: "11 actions run no middleware" for an API that checks them all.
|
|
1726
|
+
const bare = audited.filter((a) => !a.client && basename(a.file) !== HOST_ACTIONS_MODULE);
|
|
1678
1727
|
if (bare.length > 0) {
|
|
1679
1728
|
const byFile = new Map();
|
|
1680
1729
|
for (const a of bare)
|
|
@@ -1961,6 +2010,105 @@ function renderRouteTypes(manifest) {
|
|
|
1961
2010
|
"",
|
|
1962
2011
|
].join("\n");
|
|
1963
2012
|
}
|
|
2013
|
+
/** A property name as TypeScript accepts it. */
|
|
2014
|
+
const tsKey = (name) => (/^[A-Za-z_$][\w$]*$/.test(name) ? name : JSON.stringify(name));
|
|
2015
|
+
/**
|
|
2016
|
+
* A backend's JSON Schema as a TypeScript type. Named types are written once,
|
|
2017
|
+
* in the RscHost namespace, and referred to; anything the schema does not say
|
|
2018
|
+
* is unknown rather than any, so a gap is visible where it is used.
|
|
2019
|
+
*/
|
|
2020
|
+
function tsOf(schema) {
|
|
2021
|
+
if (!schema || Object.keys(schema).length === 0)
|
|
2022
|
+
return "unknown";
|
|
2023
|
+
const ref = schema.$ref;
|
|
2024
|
+
if (typeof ref === "string" && ref.startsWith("#/defs/"))
|
|
2025
|
+
return "RscHost." + ref.slice(7);
|
|
2026
|
+
if (Array.isArray(schema.anyOf)) {
|
|
2027
|
+
return [...new Set(schema.anyOf.map(tsOf))].join(" | ");
|
|
2028
|
+
}
|
|
2029
|
+
// A backed enum: its values, each a literal.
|
|
2030
|
+
if (Array.isArray(schema.enum)) {
|
|
2031
|
+
return schema.enum.map((v) => JSON.stringify(v)).join(" | ") || "never";
|
|
2032
|
+
}
|
|
2033
|
+
switch (schema.type) {
|
|
2034
|
+
case "string":
|
|
2035
|
+
return "string";
|
|
2036
|
+
case "integer":
|
|
2037
|
+
case "number":
|
|
2038
|
+
return "number";
|
|
2039
|
+
case "boolean":
|
|
2040
|
+
return "boolean";
|
|
2041
|
+
case "null":
|
|
2042
|
+
return "null";
|
|
2043
|
+
case "array":
|
|
2044
|
+
return "Array<" + tsOf(schema.items) + ">";
|
|
2045
|
+
case "object": {
|
|
2046
|
+
const properties = schema.properties;
|
|
2047
|
+
if (properties) {
|
|
2048
|
+
const required = new Set(schema.required ?? []);
|
|
2049
|
+
const fields = Object.entries(properties).map(([name, s]) => tsKey(name) + (required.has(name) ? "" : "?") + ": " + tsOf(s));
|
|
2050
|
+
return "{ " + fields.join("; ") + (fields.length ? " }" : "}");
|
|
2051
|
+
}
|
|
2052
|
+
return "Record<string, " + tsOf(schema.additionalProperties) + ">";
|
|
2053
|
+
}
|
|
2054
|
+
default:
|
|
2055
|
+
return "unknown";
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
/** A typed function's parameter list: positional, trailing pointers optional, a variadic rest. */
|
|
2059
|
+
function tsParams(sig) {
|
|
2060
|
+
const optionalFrom = sig.params.length - (sig.optional ?? 0);
|
|
2061
|
+
const params = sig.params.map((p, i) => "arg" + (i + 1) + (i >= optionalFrom ? "?" : "") + ": " + tsOf(p));
|
|
2062
|
+
if (sig.rest)
|
|
2063
|
+
params.push("...rest: Array<" + tsOf(sig.rest) + ">");
|
|
2064
|
+
return params.join(", ");
|
|
2065
|
+
}
|
|
2066
|
+
/** The first parameter's schema with its reference followed, for decoding a form into it. */
|
|
2067
|
+
function firstParamSchema(sig) {
|
|
2068
|
+
const first = sig?.params[0];
|
|
2069
|
+
const ref = first?.$ref;
|
|
2070
|
+
if (typeof ref === "string" && ref.startsWith("#/defs/"))
|
|
2071
|
+
return hostDefs[ref.slice(7)] ?? null;
|
|
2072
|
+
return first ?? null;
|
|
2073
|
+
}
|
|
2074
|
+
/**
|
|
2075
|
+
* A form posted to a backend action. React hands the action a FormData; on
|
|
2076
|
+
* the wire to the backend it is JSON, and JSON.stringify of a FormData is {} -
|
|
2077
|
+
* so a form posting straight to a backend action used to send nothing. Its
|
|
2078
|
+
* fields become one object, typed by the first parameter where the backend
|
|
2079
|
+
* described it: numbers and booleans coerced, a repeated name a list.
|
|
2080
|
+
*/
|
|
2081
|
+
const FORM_ARGS = `function formArgs(args: unknown[], schema: Record<string, any> | null): unknown[] {
|
|
2082
|
+
if (args.length !== 1 || !(args[0] instanceof FormData)) return args
|
|
2083
|
+
|
|
2084
|
+
const form = args[0] as FormData
|
|
2085
|
+
const props: Record<string, any> = schema?.properties ?? {}
|
|
2086
|
+
const coerce = (type: unknown, v: string): unknown =>
|
|
2087
|
+
type === 'integer' || type === 'number' ? (v === '' ? null : Number(v))
|
|
2088
|
+
: type === 'boolean' ? v === 'on' || v === 'true' || v === '1'
|
|
2089
|
+
: v
|
|
2090
|
+
const out: Record<string, unknown> = {}
|
|
2091
|
+
|
|
2092
|
+
for (const key of new Set(form.keys())) {
|
|
2093
|
+
// React's own fields for a form action, not the form's.
|
|
2094
|
+
if (key.startsWith('$ACTION_')) continue
|
|
2095
|
+
|
|
2096
|
+
const values = form.getAll(key).filter((v): v is string => typeof v === 'string')
|
|
2097
|
+
const prop = props[key]
|
|
2098
|
+
|
|
2099
|
+
out[key] = prop?.type === 'array' || (!prop && values.length > 1)
|
|
2100
|
+
? values.map((v) => coerce(prop?.items?.type, v))
|
|
2101
|
+
: coerce(prop?.type, values[0] ?? '')
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
// An unchecked checkbox sends nothing: false, not missing.
|
|
2105
|
+
for (const [key, prop] of Object.entries(props)) {
|
|
2106
|
+
if (!(key in out) && prop?.type === 'boolean') out[key] = false
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
return [out]
|
|
2110
|
+
}
|
|
2111
|
+
`;
|
|
1964
2112
|
/** The "use server" module exposing each host function as a plain async call. */
|
|
1965
2113
|
function renderHostActions() {
|
|
1966
2114
|
const lines = [
|
|
@@ -1968,13 +2116,24 @@ function renderHostActions() {
|
|
|
1968
2116
|
"// @generated — do not edit. Written by the RSC build from the host action map.",
|
|
1969
2117
|
"",
|
|
1970
2118
|
];
|
|
2119
|
+
lines.push(FORM_ARGS);
|
|
1971
2120
|
for (const [name, target] of Object.entries(hostActions)) {
|
|
2121
|
+
const sig = hostTypes[target];
|
|
2122
|
+
// Typed where the backend described it: its own parameters, or the form
|
|
2123
|
+
// that posts to it, and what it returns. Untyped otherwise, as before.
|
|
2124
|
+
if (sig) {
|
|
2125
|
+
const result = "Promise<" + (sig.result ? tsOf(sig.result) : "void") + ">";
|
|
2126
|
+
lines.push("export async function " + name + "(" + tsParams(sig) + "): " + result + ";");
|
|
2127
|
+
lines.push("export async function " + name + "(form: FormData): " + result + ";");
|
|
2128
|
+
}
|
|
1972
2129
|
lines.push("export async function " + name + "(...args: unknown[]) {");
|
|
1973
2130
|
lines.push(" return await (globalThis as any)." +
|
|
1974
2131
|
hostGlobal +
|
|
1975
2132
|
"(" +
|
|
1976
2133
|
JSON.stringify(target) +
|
|
1977
|
-
", ...args
|
|
2134
|
+
", ...formArgs(args, " +
|
|
2135
|
+
JSON.stringify(firstParamSchema(sig)) +
|
|
2136
|
+
"));");
|
|
1978
2137
|
lines.push("}");
|
|
1979
2138
|
lines.push("");
|
|
1980
2139
|
}
|
|
@@ -1994,12 +2153,48 @@ function renderHostGlobalTypes() {
|
|
|
1994
2153
|
"// Deliberately not a module — no import/export — so the declaration is",
|
|
1995
2154
|
"// global to the project without every file having to reference it.",
|
|
1996
2155
|
"",
|
|
1997
|
-
|
|
1998
|
-
hostGlobal +
|
|
1999
|
-
"<T = unknown>(name: string, ...args: unknown[]): Promise<T>;",
|
|
2000
|
-
"",
|
|
2156
|
+
...renderHostSignatures(),
|
|
2001
2157
|
].join("\n");
|
|
2002
2158
|
}
|
|
2159
|
+
/**
|
|
2160
|
+
* The rpc() declaration, as precisely as the backend described itself.
|
|
2161
|
+
*
|
|
2162
|
+
* A function the backend typed gets its own overload: its parameters, and
|
|
2163
|
+
* its result as the default type, so rpc('Orders.recent', 5) is Order[]
|
|
2164
|
+
* without a type argument. One it named but did not type takes anything and
|
|
2165
|
+
* returns what the caller says. With no list at all, any name.
|
|
2166
|
+
*/
|
|
2167
|
+
function renderHostSignatures() {
|
|
2168
|
+
const out = [];
|
|
2169
|
+
const typed = Object.keys(hostTypes).filter((name) => !name.startsWith("__rsc.")).sort();
|
|
2170
|
+
if (Object.keys(hostDefs).length > 0) {
|
|
2171
|
+
out.push("// The backend's own types, from " + HOST_FILE + ".", "declare namespace RscHost {");
|
|
2172
|
+
for (const name of Object.keys(hostDefs).sort()) {
|
|
2173
|
+
const schema = hostDefs[name];
|
|
2174
|
+
out.push(schema.type === "object" && schema.properties
|
|
2175
|
+
? " interface " + name + " " + tsOf(schema)
|
|
2176
|
+
: " type " + name + " = " + tsOf(schema) + ";");
|
|
2177
|
+
}
|
|
2178
|
+
out.push("}", "");
|
|
2179
|
+
}
|
|
2180
|
+
for (const name of typed) {
|
|
2181
|
+
const sig = hostTypes[name];
|
|
2182
|
+
const params = tsParams(sig);
|
|
2183
|
+
out.push("declare function " + hostGlobal + "<T = " + (sig.result ? tsOf(sig.result) : "void") + ">(name: " +
|
|
2184
|
+
JSON.stringify(name) + (params ? ", " + params : "") + "): Promise<T>;");
|
|
2185
|
+
}
|
|
2186
|
+
const untyped = hostFunctions?.filter((name) => !hostTypes[name]) ?? null;
|
|
2187
|
+
if (untyped === null) {
|
|
2188
|
+
out.push("declare function " + hostGlobal + "<T = unknown>(name: string, ...args: unknown[]): Promise<T>;");
|
|
2189
|
+
}
|
|
2190
|
+
else if (untyped.length > 0) {
|
|
2191
|
+
// The names the backend listed, so a misspelt one fails the typecheck
|
|
2192
|
+
// rather than the render.
|
|
2193
|
+
out.push("// From " + HOST_FILE + ", which the backend writes.", "type RscHostFunction =", ...untyped.map((name, i) => " | " + JSON.stringify(name) + (i === untyped.length - 1 ? ";" : "")), "declare function " + hostGlobal + "<T = unknown>(name: RscHostFunction, ...args: unknown[]): Promise<T>;");
|
|
2194
|
+
}
|
|
2195
|
+
out.push("");
|
|
2196
|
+
return out;
|
|
2197
|
+
}
|
|
2003
2198
|
// ── Discovery ────────────────────────────────────────────────────────────────
|
|
2004
2199
|
const ROUTE_FILES = [
|
|
2005
2200
|
"page",
|
|
@@ -2322,10 +2517,25 @@ const FALLBACK_CONSTS = `const FALLBACK_ORIGIN = __ORIGIN__
|
|
|
2322
2517
|
const FALLBACK_MARKER = 'x-rsc-renderer-fallback'
|
|
2323
2518
|
const PROXIED_MARKER = 'x-rsc-proxied-by-backend'
|
|
2324
2519
|
`;
|
|
2520
|
+
/** With no backend configured: this app's not-found page, or a test's backend. */
|
|
2521
|
+
const NO_FALLBACK_BODY = ` const answer = await devHandler(request)
|
|
2522
|
+
|
|
2523
|
+
if (answer) return answer
|
|
2524
|
+
|
|
2525
|
+
if (backendForward && !pageSaidNotFound(request)) return await backendForward(request)
|
|
2526
|
+
|
|
2527
|
+
return await notFound(request)
|
|
2528
|
+
`;
|
|
2325
2529
|
const FALLBACK_BODY = ` const answer = await devHandler(request)
|
|
2326
2530
|
|
|
2327
2531
|
if (answer) return answer
|
|
2328
2532
|
|
|
2533
|
+
// A page of this app said there is nothing here - notFound() in it or in
|
|
2534
|
+
// its guard, or a backend's 404. That is this app's not-found.tsx, not a
|
|
2535
|
+
// url for the backend: forwarded, the backend answered with its own 404
|
|
2536
|
+
// and the app's never showed.
|
|
2537
|
+
if (pageSaidNotFound(request)) return await notFound(request)
|
|
2538
|
+
|
|
2329
2539
|
// Nothing here owns this url. In development the backend usually does — a
|
|
2330
2540
|
// Blade page, /login, a webhook, an uploaded file under /storage — so the
|
|
2331
2541
|
// request is handed on rather than refused, and this origin is the whole
|
|
@@ -2383,6 +2593,18 @@ const FALLBACK_BODY = ` const answer = await devHandler(request)
|
|
|
2383
2593
|
|
|
2384
2594
|
const hasBody = request.method !== 'GET' && request.method !== 'HEAD'
|
|
2385
2595
|
|
|
2596
|
+
// A test's backend: the same request, answered in-process.
|
|
2597
|
+
if (backendForward) {
|
|
2598
|
+
return await backendForward(
|
|
2599
|
+
new Request(target, {
|
|
2600
|
+
method: request.method,
|
|
2601
|
+
headers,
|
|
2602
|
+
body: hasBody ? request.body : undefined,
|
|
2603
|
+
...(hasBody ? { duplex: 'half' } : {}),
|
|
2604
|
+
} as RequestInit),
|
|
2605
|
+
)
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2386
2608
|
try {
|
|
2387
2609
|
return await fetch(target, {
|
|
2388
2610
|
method: request.method,
|
|
@@ -2461,6 +2683,11 @@ function installHostCallsOnce(): void {
|
|
|
2461
2683
|
if (hostInstalled) return
|
|
2462
2684
|
hostInstalled = true
|
|
2463
2685
|
|
|
2686
|
+
// One installed already - a test's, through createTestApp({ host }) - is
|
|
2687
|
+
// the host. Replacing it with the backend in .env would send the test's
|
|
2688
|
+
// calls to a server that may not be running.
|
|
2689
|
+
if (currentHost) return
|
|
2690
|
+
|
|
2464
2691
|
const origin = process.env.RSC_BACKEND ?? process.env.APP_URL
|
|
2465
2692
|
const secret = process.env.RSC_HOST_CALL_SECRET
|
|
2466
2693
|
|
|
@@ -2610,7 +2837,7 @@ import { notFoundDigest, isNotFoundSignal } from ${JSON.stringify(join(packageDi
|
|
|
2610
2837
|
import { noteRequestRead, urlOf } from ${JSON.stringify(join(packageDir, "request"))}
|
|
2611
2838
|
import { redirectDigest } from ${JSON.stringify(join(packageDir, "redirectDigest"))}
|
|
2612
2839
|
import { cancelledByConsumer } from ${JSON.stringify(join(packageDir, "js/fallbackReport"))}
|
|
2613
|
-
import { createRscHandler, queryAndParams } from ${JSON.stringify(join(packageDir, "host"))}
|
|
2840
|
+
import { createRscHandler, pageSaidNotFound, queryAndParams } from ${JSON.stringify(join(packageDir, "host"))}
|
|
2614
2841
|
import { httpHostCalls } from ${JSON.stringify(join(packageDir, "hostCalls"))}
|
|
2615
2842
|
import { prerenderedBeside } from ${JSON.stringify(join(packageDir, "files"))}
|
|
2616
2843
|
import { renderToReadableStream, decodeReply, decodeAction, decodeFormState, loadServerAction } from '@vitejs/plugin-rsc/rsc'
|
|
@@ -2619,7 +2846,7 @@ import { isActionValidationError, isClientBuilt } from ${JSON.stringify(join(pac
|
|
|
2619
2846
|
import { forgetCached } from ${JSON.stringify(join(packageDir, "cache"))}
|
|
2620
2847
|
import { noteFallback as noteCaughtRead } from ${JSON.stringify(join(packageDir, "request"))}
|
|
2621
2848
|
import { isOutdatedOptimizedDep, outdatedDepResponse } from ${JSON.stringify(join(packageDir, "devReload"))}
|
|
2622
|
-
import { sharedDepth } from ${JSON.stringify(join(packageDir, "routing"))}
|
|
2849
|
+
import { paramsFor, segmentNames, sharedDepth } from ${JSON.stringify(join(packageDir, "routing"))}
|
|
2623
2850
|
import { Suspense, createElement, Fragment } from 'react'
|
|
2624
2851
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2625
2852
|
${imports.join("\n")}
|
|
@@ -3059,6 +3286,19 @@ if (__instrumentation.register && !isolateRuntime) {
|
|
|
3059
3286
|
|
|
3060
3287
|
let currentHost: HostFn | null = null
|
|
3061
3288
|
|
|
3289
|
+
/**
|
|
3290
|
+
* What answers a url this app does not own, in place of the backend.
|
|
3291
|
+
*
|
|
3292
|
+
* For createTestApp({ backend }): a url the app forwards to its backend
|
|
3293
|
+
* reaches the test instead of a server that is not running, which answered
|
|
3294
|
+
* every one with a 502.
|
|
3295
|
+
*/
|
|
3296
|
+
let backendForward: ((request: Request) => Response | Promise<Response>) | null = null
|
|
3297
|
+
|
|
3298
|
+
export function installBackendForward(fn: ((request: Request) => Response | Promise<Response>) | null) {
|
|
3299
|
+
backendForward = fn
|
|
3300
|
+
}
|
|
3301
|
+
|
|
3062
3302
|
export function installHostFn(fn: HostFn) {
|
|
3063
3303
|
currentHost = fn
|
|
3064
3304
|
return () => {
|
|
@@ -3232,6 +3472,48 @@ function errorChain(component: string): string[] {
|
|
|
3232
3472
|
return errorChains[component] ?? []
|
|
3233
3473
|
}
|
|
3234
3474
|
|
|
3475
|
+
/**
|
|
3476
|
+
* The params of a render for a route's pattern rather than one url: they
|
|
3477
|
+
* never settle, so whatever awaits them suspends and the rest is the shell.
|
|
3478
|
+
* Marked, so a layout awaiting them can be named in the build's refusal.
|
|
3479
|
+
*/
|
|
3480
|
+
const unknownParams = new WeakSet<Promise<unknown>>()
|
|
3481
|
+
|
|
3482
|
+
function patternParams(): Promise<Record<string, unknown>> {
|
|
3483
|
+
const never = new Promise<Record<string, unknown>>(() => {})
|
|
3484
|
+
|
|
3485
|
+
unknownParams.add(never)
|
|
3486
|
+
|
|
3487
|
+
return never
|
|
3488
|
+
}
|
|
3489
|
+
|
|
3490
|
+
/**
|
|
3491
|
+
* What a layout is handed as params: its own segments' and those above it.
|
|
3492
|
+
*
|
|
3493
|
+
* During the pattern render, a layout with no dynamic segment of its own has
|
|
3494
|
+
* nothing to wait for and gets its empty params at once. One with a segment
|
|
3495
|
+
* waits forever, and says so - "params in app/[team]/layout" - so the build
|
|
3496
|
+
* names the layout rather than whichever read happened to be last.
|
|
3497
|
+
*/
|
|
3498
|
+
function layoutParams(component: string, params: Promise<Record<string, unknown>>): Promise<Record<string, unknown>> {
|
|
3499
|
+
if (!unknownParams.has(params)) return params.then((all) => paramsFor(component, all))
|
|
3500
|
+
|
|
3501
|
+
if (segmentNames(component).length === 0) return Promise.resolve({})
|
|
3502
|
+
|
|
3503
|
+
const by = 'params in ' + (component.startsWith('app/') ? component.slice(4) : component)
|
|
3504
|
+
|
|
3505
|
+
return {
|
|
3506
|
+
then(resolve, reject) {
|
|
3507
|
+
noteRequestRead(by)
|
|
3508
|
+
|
|
3509
|
+
return params.then(resolve, reject)
|
|
3510
|
+
},
|
|
3511
|
+
catch: (reject) => params.catch(reject),
|
|
3512
|
+
finally: (done) => params.finally(done),
|
|
3513
|
+
[Symbol.toStringTag]: 'Promise',
|
|
3514
|
+
} as Promise<Record<string, unknown>>
|
|
3515
|
+
}
|
|
3516
|
+
|
|
3235
3517
|
// Composition: layout(outer..inner) > Suspense(loading, innermost-first) > page.
|
|
3236
3518
|
function buildElement(
|
|
3237
3519
|
component: string,
|
|
@@ -3391,8 +3673,13 @@ function buildElement(
|
|
|
3391
3673
|
element = createElement(SegmentBoundary, { depth: i + 1, pageKey }, withPathname(element))
|
|
3392
3674
|
}
|
|
3393
3675
|
|
|
3676
|
+
// Its own segments' params and those above it, awaitable as a page's
|
|
3677
|
+
// are: a layout above [team] is kept mounted across teams, so a deeper
|
|
3678
|
+
// value handed to it would go stale. See paramsFor.
|
|
3679
|
+
const own = layouts[i].component
|
|
3394
3680
|
element = createElement(Layout, {
|
|
3395
3681
|
...(layouts[i].props ?? {}),
|
|
3682
|
+
params: layoutParams(own, params),
|
|
3396
3683
|
...(slotsByLayout.get(i) ?? {}),
|
|
3397
3684
|
children: element,
|
|
3398
3685
|
})
|
|
@@ -4142,7 +4429,18 @@ interface PageContext {
|
|
|
4142
4429
|
* A slot is the only unit smaller than a page the server can name, which is
|
|
4143
4430
|
* why two tables have to be slots to be refreshed apart from each other.
|
|
4144
4431
|
*/
|
|
4145
|
-
|
|
4432
|
+
/** What renderRevalidated answers for a region the calling page does not have. */
|
|
4433
|
+
const SKIPPED = Symbol('rsc-kit.revalidate-skipped')
|
|
4434
|
+
|
|
4435
|
+
/** Whether any page in the app has a section or slot by this name. */
|
|
4436
|
+
function regionOfSomePage(target: string): boolean {
|
|
4437
|
+
return manifest().routes.some((route: any) =>
|
|
4438
|
+
(route.sections ?? []).some((name: string) => name.split('/').pop() === target + '.section') ||
|
|
4439
|
+
Object.hasOwn(route.slots ?? {}, target),
|
|
4440
|
+
)
|
|
4441
|
+
}
|
|
4442
|
+
|
|
4443
|
+
async function renderRevalidated(target: string, page: PageContext, skipElsewhere = false): Promise<unknown> {
|
|
4146
4444
|
// Every target below 'all' renders without the layout chain above it, which
|
|
4147
4445
|
// is the same skip a navigation performs and needs the same guard run.
|
|
4148
4446
|
await runMiddleware(page.component, page.props)
|
|
@@ -4205,6 +4503,12 @@ async function renderRevalidated(target: string, page: PageContext): Promise<unk
|
|
|
4205
4503
|
? page.parallelSlots[target]
|
|
4206
4504
|
: undefined
|
|
4207
4505
|
|
|
4506
|
+
// A region of another page. An action names what it changed, not where
|
|
4507
|
+
// it was called from - a backend's above all, which cannot know - so a
|
|
4508
|
+
// region the calling page does not show is nothing to refresh here. A name
|
|
4509
|
+
// no page has is still a mistake, and refused below.
|
|
4510
|
+
if (!slotComponent && skipElsewhere && regionOfSomePage(target)) return SKIPPED
|
|
4511
|
+
|
|
4208
4512
|
if (!slotComponent) {
|
|
4209
4513
|
throw new Error(
|
|
4210
4514
|
'Cannot revalidate ' + target + ': no section or slot of this page by that name. ' +
|
|
@@ -4383,7 +4687,9 @@ export async function handleAction(
|
|
|
4383
4687
|
const revalidated: Record<string, unknown> = {}
|
|
4384
4688
|
|
|
4385
4689
|
for (const target of targets) {
|
|
4386
|
-
|
|
4690
|
+
const tree = await renderRevalidated(target, page, true)
|
|
4691
|
+
|
|
4692
|
+
if (tree !== SKIPPED) revalidated[target] = tree
|
|
4387
4693
|
}
|
|
4388
4694
|
|
|
4389
4695
|
// Marked, so an action whose own result happens to be an object with a
|
|
@@ -4849,7 +5155,7 @@ export async function handleRscPprShell(
|
|
|
4849
5155
|
0,
|
|
4850
5156
|
pageKey,
|
|
4851
5157
|
true,
|
|
4852
|
-
pageKey ? Promise.resolve(props) :
|
|
5158
|
+
pageKey ? Promise.resolve(props) : patternParams(),
|
|
4853
5159
|
)
|
|
4854
5160
|
// Quiet about a redirect: during the probe it is a classification, not
|
|
4855
5161
|
// a failure, and React would otherwise print a stack for every one.
|
|
@@ -4973,7 +5279,7 @@ async function serve(request: Request): Promise<Response> {
|
|
|
4973
5279
|
${NITRO_HANDLER_OPTIONS}${NITRO_PRERENDERED} maxActionBody: ${maxActionBody === undefined ? "undefined" : String(maxActionBody)},
|
|
4974
5280
|
})
|
|
4975
5281
|
|
|
4976
|
-
${fallbackOrigin ? FALLBACK_BODY :
|
|
5282
|
+
${fallbackOrigin ? FALLBACK_BODY : NO_FALLBACK_BODY}}
|
|
4977
5283
|
|
|
4978
5284
|
/**
|
|
4979
5285
|
* The page for a url nothing answers.
|
|
@@ -6224,6 +6530,7 @@ export function rscKit(options = {}) {
|
|
|
6224
6530
|
},
|
|
6225
6531
|
},
|
|
6226
6532
|
config(_config, env) {
|
|
6533
|
+
runHostManifest(options, env.command === "build");
|
|
6227
6534
|
if (!existsSync(appDir)) {
|
|
6228
6535
|
throw new Error(`[rsc-kit] No app directory at ${appDir} — nothing to build.`);
|
|
6229
6536
|
}
|
|
@@ -6674,11 +6981,11 @@ export function rscKit(options = {}) {
|
|
|
6674
6981
|
// dev server - rewrites the file, and until the server started again
|
|
6675
6982
|
// the stub was not there to import. Any event: the file appears on a
|
|
6676
6983
|
// first action, changes on the next, and goes when the last is removed.
|
|
6677
|
-
const hostActionsPath = join(projectRoot,
|
|
6984
|
+
const hostActionsPath = join(projectRoot, HOST_FILE);
|
|
6678
6985
|
const hostActionsChanged = (file) => {
|
|
6679
6986
|
if (file !== hostActionsPath)
|
|
6680
6987
|
return;
|
|
6681
|
-
server.config.logger.info(`[rsc-kit] ${
|
|
6988
|
+
server.config.logger.info(`[rsc-kit] ${relative(projectRoot, file)} changed — restarting`);
|
|
6682
6989
|
void server.restart();
|
|
6683
6990
|
};
|
|
6684
6991
|
server.watcher.add(hostActionsPath);
|