@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/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 HOST_ACTIONS_FILE = "rsc-host-actions.json";
234
- /**
235
- * Host actions, read from a file the backend wrote.
236
- *
237
- * A file rather than an environment variable, because the backend no longer
238
- * drives the build — `vite build` does. Discovery has to stay where the classes
239
- * are (reflection through Composer's autoloader finds what a class inherits;
240
- * a regex would silently miss every inherited action), but the handoff is just
241
- * a map of names, and a JSON file is something any language can write:
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 parsed;
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
- // Loud, because the alternative is generating no stubs: every import of a
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
- hostActions = options.hostActions ?? fileHostActions(projectRoot);
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, "server-actions.generated.ts");
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
- const bare = audited.filter((a) => !a.client);
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
- "declare function " +
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
- async function renderRevalidated(target: string, page: PageContext): Promise<unknown> {
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
- revalidated[target] = await renderRevalidated(target, page)
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) : new Promise(() => {}),
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 : " return (await devHandler(request)) ?? (await notFound(request))\n"}}
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, HOST_ACTIONS_FILE);
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] ${HOST_ACTIONS_FILE} changed — restarting`);
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);