@rsc-kit/core 0.30.12 → 0.30.14

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
@@ -157,6 +157,7 @@ let typesDir;
157
157
  let hotFile;
158
158
  let hostCallOptions;
159
159
  let packageDir;
160
+ let backendPaths = [];
160
161
  let hostGlobal;
161
162
  let interceptManifestFile;
162
163
  let packageAlias;
@@ -348,6 +349,34 @@ function aliasEntries() {
348
349
  });
349
350
  return entries;
350
351
  }
352
+ /**
353
+ * The prefixes a backend owns, as the matcher reads them: `/auth`, no trailing
354
+ * slash. Refused rather than corrected where a mistake would silently send the
355
+ * whole app, or its own endpoints, to the backend.
356
+ */
357
+ export function normaliseBackendPaths(paths) {
358
+ const out = [];
359
+ for (const raw of paths ?? []) {
360
+ const given = typeof raw === "string" ? raw.trim() : "";
361
+ if (!given.startsWith("/")) {
362
+ throw new Error(`[rsc-kit] backendPaths: ${JSON.stringify(raw)} is not a path. Each starts with a slash, as in '/auth'.`);
363
+ }
364
+ // After the slash check, so "/" is the root and not "not a path".
365
+ const path = given.replace(/\/+$/, "");
366
+ if (path === "") {
367
+ throw new Error("[rsc-kit] backendPaths: '/' is every url, and nothing would be left for this app. Name the prefixes the backend owns.");
368
+ }
369
+ if (path === "/_rsc" || path.startsWith("/_rsc/")) {
370
+ throw new Error(`[rsc-kit] backendPaths: ${JSON.stringify(raw)} is this package's own endpoint, which the renderer answers.`);
371
+ }
372
+ if (/[?#*]/.test(path)) {
373
+ throw new Error(`[rsc-kit] backendPaths: ${JSON.stringify(raw)} is a prefix, not a pattern: no query, fragment or wildcard.`);
374
+ }
375
+ if (!out.includes(path))
376
+ out.push(path);
377
+ }
378
+ return out;
379
+ }
351
380
  function resolvePaths(options) {
352
381
  projectRoot = resolve(options.projectRoot || process.env.RSC_PROJECT_ROOT || process.cwd());
353
382
  sourceDir = resolve(options.sourceDir || process.env.RSC_SOURCE_DIR || join(projectRoot, "src"));
@@ -385,6 +414,7 @@ function resolvePaths(options) {
385
414
  hotFile = options.hotFile || process.env.RSC_HOT_FILE || "";
386
415
  hostCallOptions = options.hostCall;
387
416
  packageDir = resolve(options.packageDir || process.env.RSC_PACKAGE_DIR || thisDir());
417
+ backendPaths = normaliseBackendPaths(options.backendPaths);
388
418
  hostGlobal = options.hostGlobal || process.env.RSC_HOST_GLOBAL || "rpc";
389
419
  interceptManifestFile = resolve(options.interceptManifestFile ||
390
420
  process.env.RSC_INTERCEPT_MANIFEST ||
@@ -2155,13 +2185,64 @@ function tsParams(sig) {
2155
2185
  params.push("...rest: Array<" + tsOf(sig.rest) + ">");
2156
2186
  return params.join(", ");
2157
2187
  }
2158
- /** The first parameter's schema with its reference followed, for decoding a form into it. */
2159
- function firstParamSchema(sig) {
2160
- const first = sig?.params[0];
2161
- const ref = first?.$ref;
2188
+ /** A parameter's schema with its reference followed, for decoding a form into it. */
2189
+ function resolveParam(param) {
2190
+ const ref = param?.$ref;
2162
2191
  if (typeof ref === "string" && ref.startsWith("#/defs/"))
2163
2192
  return hostDefs[ref.slice(7)] ?? null;
2164
- return first ?? null;
2193
+ return param ?? null;
2194
+ }
2195
+ /**
2196
+ * The parameter a form lands on: the last one that is a struct.
2197
+ *
2198
+ * A form is one object, and a backend action may take others before it - the
2199
+ * team an action is scoped to, bound with `stub.bind(null, team)` - so it is
2200
+ * the struct at the end, not the first parameter, that the fields belong to.
2201
+ * -1 when no parameter is one, and then a form is not what the action takes.
2202
+ */
2203
+ function formParam(sig) {
2204
+ const params = sig?.params ?? [];
2205
+ for (let i = params.length - 1; i >= 0; i--) {
2206
+ const schema = resolveParam(params[i]);
2207
+ if (schema && (schema.type === "object" || schema.properties))
2208
+ return i;
2209
+ }
2210
+ return -1;
2211
+ }
2212
+ /**
2213
+ * Every name a struct can be read or refused under, as a TypeScript type: its
2214
+ * fields, a nested object's `address.city`, and a list's `items.${number}`.
2215
+ * The dotted paths a validation reports, so `error()` is closed to them.
2216
+ */
2217
+ function fieldNames(schema, prefix = "", depth = 3) {
2218
+ const properties = (schema?.properties ?? {});
2219
+ const out = [];
2220
+ for (const [key, raw] of Object.entries(properties)) {
2221
+ const prop = resolveParam(raw);
2222
+ const path = prefix + key;
2223
+ out.push(path);
2224
+ if (depth === 0 || !prop)
2225
+ continue;
2226
+ if (prop.type === "array") {
2227
+ out.push(path + ".${number}");
2228
+ const item = resolveParam(prop.items);
2229
+ if (item?.properties)
2230
+ out.push(...fieldNames(item, path + ".${number}.", depth - 1));
2231
+ }
2232
+ else if (prop.properties) {
2233
+ out.push(...fieldNames(prop, path + ".", depth - 1));
2234
+ }
2235
+ }
2236
+ return out;
2237
+ }
2238
+ /** What a form posted to this action is: FormFields naming the struct's fields, or a plain FormData. */
2239
+ function formType(sig) {
2240
+ const index = formParam(sig);
2241
+ const names = index < 0 ? [] : fieldNames(resolveParam(sig.params[index]));
2242
+ if (names.length === 0)
2243
+ return "FormData";
2244
+ const literal = (name) => (name.includes("${") ? "`" + name + "`" : JSON.stringify(name));
2245
+ return "FormFields<" + [...new Set(names)].map(literal).join(" | ") + ">";
2165
2246
  }
2166
2247
  /**
2167
2248
  * A form posted to a backend action. React hands the action a FormData; on
@@ -2170,10 +2251,16 @@ function firstParamSchema(sig) {
2170
2251
  * fields become one object, typed by the first parameter where the backend
2171
2252
  * described it: numbers and booleans coerced, a repeated name a list.
2172
2253
  */
2173
- const FORM_ARGS = `function formArgs(args: unknown[], schema: Record<string, any> | null): unknown[] {
2174
- if (args.length !== 1 || !(args[0] instanceof FormData)) return args
2254
+ const FORM_ARGS = `function formArgs(args: unknown[], schemas: (Record<string, any> | null)[]): unknown[] {
2255
+ // The form is the last argument, and it fills the parameter it lands on: the
2256
+ // first when the action takes nothing before it, the one after the team when
2257
+ // that was bound with stub.bind(null, team).
2258
+ const at = args.length - 1
2175
2259
 
2176
- const form = args[0] as FormData
2260
+ if (at < 0 || !(args[at] instanceof FormData)) return args
2261
+
2262
+ const form = args[at] as FormData
2263
+ const schema = schemas[at] ?? null
2177
2264
  const props: Record<string, any> = schema?.properties ?? {}
2178
2265
  const coerce = (type: unknown, v: string): unknown =>
2179
2266
  type === 'integer' || type === 'number' ? (v === '' ? null : Number(v))
@@ -2198,7 +2285,7 @@ const FORM_ARGS = `function formArgs(args: unknown[], schema: Record<string, any
2198
2285
  if (!(key in out) && prop?.type === 'boolean') out[key] = false
2199
2286
  }
2200
2287
 
2201
- return [out]
2288
+ return [...args.slice(0, at), out]
2202
2289
  }
2203
2290
  `;
2204
2291
  /** The "use server" module exposing each host function as a plain async call. */
@@ -2209,7 +2296,8 @@ function renderHostActions() {
2209
2296
  "",
2210
2297
  ];
2211
2298
  // Type-only, so the module stays what "use server" allows: functions.
2212
- lines.push('import type { Redirected } from "@rsc-kit/core/errors";', "");
2299
+ lines.push('import type { Redirected } from "@rsc-kit/core/errors";');
2300
+ lines.push('import type { FormFields } from "@rsc-kit/core/form";', "");
2213
2301
  lines.push(FORM_ARGS);
2214
2302
  for (const [name, target] of Object.entries(hostActions)) {
2215
2303
  const sig = hostTypes[target];
@@ -2222,7 +2310,13 @@ function renderHostActions() {
2222
2310
  // it as data.
2223
2311
  const result = "Promise<" + (sig.result ? tsOf(sig.result) : "void") + " | Redirected>";
2224
2312
  lines.push("export async function " + name + "(" + tsParams(sig) + "): " + result + ";");
2225
- lines.push("export async function " + name + "(form: FormData): " + result + ";");
2313
+ // The form overload is last, which is the one `stub.bind(null, team)`
2314
+ // reads: bound, it is `(form: FormFields<…>) => …`, so a form's action
2315
+ // keeps the struct's field names. Leading parameters are the ones the
2316
+ // form does not fill; any after the struct are left to the backend.
2317
+ const at = formParam(sig);
2318
+ const leading = at > 0 ? sig.params.slice(0, at).map((p, i) => "arg" + (i + 1) + ": " + tsOf(p)) : [];
2319
+ lines.push("export async function " + name + "(" + [...leading, "form: " + formType(sig)].join(", ") + "): " + result + ";");
2226
2320
  }
2227
2321
  lines.push("export async function " + name + "(...args: unknown[]) {");
2228
2322
  lines.push(" return await (globalThis as any)." +
@@ -2230,7 +2324,7 @@ function renderHostActions() {
2230
2324
  "(" +
2231
2325
  JSON.stringify(target) +
2232
2326
  ", ...formArgs(args, " +
2233
- JSON.stringify(firstParamSchema(sig)) +
2327
+ JSON.stringify((sig?.params ?? []).map(resolveParam)) +
2234
2328
  "));");
2235
2329
  lines.push("}");
2236
2330
  lines.push("");
@@ -2705,17 +2799,40 @@ function fallbackOrigin(): string {
2705
2799
  const FALLBACK_MARKER = 'x-rsc-renderer-fallback'
2706
2800
  const PROXIED_MARKER = 'x-rsc-proxied-by-backend'
2707
2801
  `;
2802
+ /**
2803
+ * The urls the app said belong to the backend, tested before any page is
2804
+ * matched. Emitted in both variants: a test's stand-in backend is reached by
2805
+ * the same prefixes as the real one.
2806
+ */
2807
+ function backendPathConsts() {
2808
+ return `const BACKEND_PATHS: string[] = ${JSON.stringify(backendPaths)}
2809
+
2810
+ // A prefix at a segment boundary: '/auth' is '/auth' and what is under it, not '/authors'.
2811
+ function onBackendPath(pathname: string): boolean {
2812
+ return BACKEND_PATHS.some((prefix) => pathname === prefix || pathname.startsWith(prefix + '/'))
2813
+ }
2814
+ `;
2815
+ }
2708
2816
  /** With no backend configured: this app's not-found page, or a test's backend. */
2709
- const NO_FALLBACK_BODY = ` const answer = await devHandler(request)
2817
+ const NO_FALLBACK_BODY = ` // The app named this prefix as the backend's: no page is asked about it, so one
2818
+ // that matches (a dynamic root route) cannot answer for it.
2819
+ const toBackend = onBackendPath(new URL(request.url).pathname)
2820
+ const answer = toBackend ? null : await devHandler(request)
2710
2821
 
2711
2822
  if (answer) return answer
2712
2823
 
2713
- if (backendForward && !pageSaidNotFound(request)) return await backendForward(request)
2824
+ if (backendForward && (toBackend || !pageSaidNotFound(request))) return await backendForward(request)
2714
2825
 
2715
2826
  return await notFound(request)
2716
2827
  `;
2717
2828
  const FALLBACK_BODY = ` const FALLBACK_ORIGIN = fallbackOrigin()
2718
- const answer = await devHandler(request)
2829
+
2830
+ // The app named this prefix as the backend's (rscKit({ backendPaths })): no
2831
+ // page is asked about it. A dynamic root route such as [team]/[app] matches
2832
+ // /gitlab/connect, renders, says notFound() - and a page that said that is not
2833
+ // forwarded, so the backend's own route was the not-found page.
2834
+ const toBackend = onBackendPath(new URL(request.url).pathname)
2835
+ const answer = toBackend ? null : await devHandler(request)
2719
2836
 
2720
2837
  if (answer) return answer
2721
2838
 
@@ -2723,7 +2840,7 @@ const FALLBACK_BODY = ` const FALLBACK_ORIGIN = fallbackOrigin()
2723
2840
  // its guard, or a backend's 404. That is this app's not-found.tsx, not a
2724
2841
  // url for the backend: forwarded, the backend answered with its own 404
2725
2842
  // and the app's never showed.
2726
- if (pageSaidNotFound(request)) return await notFound(request)
2843
+ if (!toBackend && pageSaidNotFound(request)) return await notFound(request)
2727
2844
 
2728
2845
  // Nothing here owns this url. In development the backend usually does — a
2729
2846
  // Blade page, /login, a webhook, an uploaded file under /storage — so the
@@ -5643,7 +5760,7 @@ export async function handleRscPprShell(
5643
5760
  * server rendering live pages it had already frozen. The gate is the mode, not
5644
5761
  * the file.
5645
5762
  */
5646
- ${fallbackOrigin === false ? "" : FALLBACK_CONSTS.replace("__ORIGIN__", JSON.stringify(fallbackOrigin))}${NITRO_HOST_CALLS}
5763
+ ${backendPathConsts()}${fallbackOrigin === false ? "" : FALLBACK_CONSTS.replace("__ORIGIN__", JSON.stringify(fallbackOrigin))}${NITRO_HOST_CALLS}
5647
5764
  let devHandler: ((request: Request) => Promise<Response | null>) | null = null
5648
5765
 
5649
5766
  export default async function handler(request: Request): Promise<Response> {