@rsc-kit/core 0.2.0 → 0.4.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/action.d.ts +2 -1
- package/dist/action.d.ts.map +1 -1
- package/dist/action.js +21 -1
- package/dist/action.js.map +1 -1
- package/dist/host.d.ts +9 -0
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +76 -10
- package/dist/host.js.map +1 -1
- package/dist/hostCalls.d.ts +86 -0
- package/dist/hostCalls.d.ts.map +1 -0
- package/dist/hostCalls.js +144 -0
- package/dist/hostCalls.js.map +1 -0
- package/dist/manifest.d.ts +15 -0
- package/dist/manifest.d.ts.map +1 -1
- package/dist/nondeterminism.d.ts +15 -0
- package/dist/nondeterminism.d.ts.map +1 -0
- package/dist/nondeterminism.js +86 -0
- package/dist/nondeterminism.js.map +1 -0
- package/dist/prerender.d.ts +20 -1
- package/dist/prerender.d.ts.map +1 -1
- package/dist/prerender.js +83 -11
- package/dist/prerender.js.map +1 -1
- package/dist/request.d.ts +32 -0
- package/dist/request.d.ts.map +1 -1
- package/dist/request.js +38 -0
- package/dist/request.js.map +1 -1
- package/dist/serve.d.ts +55 -0
- package/dist/serve.d.ts.map +1 -0
- package/dist/serve.js +40 -0
- package/dist/serve.js.map +1 -0
- package/dist/vite.d.ts +27 -0
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +407 -23
- package/dist/vite.js.map +1 -1
- package/package.json +9 -9
- package/dist/streaming.d.ts +0 -62
- package/dist/streaming.d.ts.map +0 -1
- package/dist/streaming.js +0 -108
- package/dist/streaming.js.map +0 -1
- package/dist/worker.d.ts +0 -2
- package/dist/worker.d.ts.map +0 -1
- package/dist/worker.js +0 -926
- package/dist/worker.js.map +0 -1
package/dist/vite.js
CHANGED
|
@@ -17,6 +17,8 @@ import { createRequire } from 'node:module';
|
|
|
17
17
|
import { dirname, join, relative, resolve } from 'node:path';
|
|
18
18
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
19
19
|
import rsc from '@vitejs/plugin-rsc';
|
|
20
|
+
import { loadEnv } from 'vite';
|
|
21
|
+
import { httpHostCalls } from './hostCalls.js';
|
|
20
22
|
// Resolved once per rscRoutes() call. One build runs in one process, so these are
|
|
21
23
|
// module state rather than threaded through every helper.
|
|
22
24
|
let projectRoot;
|
|
@@ -26,6 +28,8 @@ let appDir;
|
|
|
26
28
|
let genDir;
|
|
27
29
|
let publicAssetsDir;
|
|
28
30
|
let assetsBaseUrl;
|
|
31
|
+
let hotFile;
|
|
32
|
+
let hostCallOptions;
|
|
29
33
|
let packageDir;
|
|
30
34
|
let hostGlobal;
|
|
31
35
|
let interceptManifestFile;
|
|
@@ -58,6 +62,16 @@ let hostActions;
|
|
|
58
62
|
* the ordinary case for an app that installs the engine from npm and runs
|
|
59
63
|
* `vite build` itself.
|
|
60
64
|
*/
|
|
65
|
+
/** This package's name, for excluding it from dep optimization. */
|
|
66
|
+
const PACKAGE_NAME = (() => {
|
|
67
|
+
try {
|
|
68
|
+
const manifest = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf-8'));
|
|
69
|
+
return manifest.name ?? '@rsc-kit/core';
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return '@rsc-kit/core';
|
|
73
|
+
}
|
|
74
|
+
})();
|
|
61
75
|
function thisDir() {
|
|
62
76
|
return dirname(fileURLToPath(import.meta.url));
|
|
63
77
|
}
|
|
@@ -69,12 +83,40 @@ function envRouteConfig() {
|
|
|
69
83
|
return null;
|
|
70
84
|
return { file, dynamicPattern: new RegExp(pattern) };
|
|
71
85
|
}
|
|
72
|
-
/**
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
86
|
+
/** The file a backend writes its action names into. */
|
|
87
|
+
const HOST_ACTIONS_FILE = 'rsc-host-actions.json';
|
|
88
|
+
/**
|
|
89
|
+
* Host actions, read from a file the backend wrote.
|
|
90
|
+
*
|
|
91
|
+
* A file rather than an environment variable, because the backend no longer
|
|
92
|
+
* drives the build — `vite build` does. Discovery has to stay where the classes
|
|
93
|
+
* are (reflection through Composer's autoloader finds what a class inherits;
|
|
94
|
+
* a regex would silently miss every inherited action), but the handoff is just
|
|
95
|
+
* a map of names, and a JSON file is something any language can write:
|
|
96
|
+
*
|
|
97
|
+
* php artisan rsc:action-manifest > rsc-host-actions.json
|
|
98
|
+
* go run ./cmd/rsc-actions > rsc-host-actions.json
|
|
99
|
+
*
|
|
100
|
+
* Absent is not an error. An app with no host actions has no file, and one
|
|
101
|
+
* that has them regenerates it as part of its build.
|
|
102
|
+
*/
|
|
103
|
+
function fileHostActions(root) {
|
|
104
|
+
const path = join(root, HOST_ACTIONS_FILE);
|
|
105
|
+
if (!existsSync(path))
|
|
76
106
|
return {};
|
|
77
|
-
|
|
107
|
+
try {
|
|
108
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8'));
|
|
109
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
110
|
+
throw new Error('expected an object of { jsName: "Class.method" }');
|
|
111
|
+
}
|
|
112
|
+
return parsed;
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
// Loud, because the alternative is generating no stubs: every import of a
|
|
116
|
+
// server action then fails at build time, naming the import rather than
|
|
117
|
+
// this file.
|
|
118
|
+
throw new Error(`Could not read ${HOST_ACTIONS_FILE}: ${error instanceof Error ? error.message : String(error)}`);
|
|
119
|
+
}
|
|
78
120
|
}
|
|
79
121
|
/**
|
|
80
122
|
* The alias that lets a vendored copy of this package be imported by name.
|
|
@@ -110,6 +152,8 @@ function resolvePaths(options) {
|
|
|
110
152
|
// bundles are SERVER code and stay under outDir, which must never be public.
|
|
111
153
|
publicAssetsDir = resolve(options.assetsDir || process.env.RSC_ASSETS_DIR || join(projectRoot, 'dist/client'));
|
|
112
154
|
assetsBaseUrl = options.assetsUrl || process.env.RSC_ASSETS_URL || '/';
|
|
155
|
+
hotFile = options.hotFile || process.env.RSC_HOT_FILE || '';
|
|
156
|
+
hostCallOptions = options.hostCall;
|
|
113
157
|
packageDir = resolve(options.packageDir || process.env.RSC_PACKAGE_DIR || thisDir());
|
|
114
158
|
hostGlobal = options.hostGlobal || process.env.RSC_HOST_GLOBAL || 'rpc';
|
|
115
159
|
interceptManifestFile = resolve(options.interceptManifestFile || process.env.RSC_INTERCEPT_MANIFEST || join(outDir, 'intercept-manifest.json'));
|
|
@@ -130,7 +174,7 @@ function resolvePaths(options) {
|
|
|
130
174
|
// A host driving the build out of process cannot pass an option, and may
|
|
131
175
|
// prerender itself afterwards with paths only it knows.
|
|
132
176
|
prerenderAfterBuild = options.prerender ?? process.env.RSC_PRERENDER !== '0';
|
|
133
|
-
hostActions = options.hostActions ??
|
|
177
|
+
hostActions = options.hostActions ?? fileHostActions(projectRoot);
|
|
134
178
|
}
|
|
135
179
|
function log(...args) {
|
|
136
180
|
console.error('[rsc-routes]', ...args);
|
|
@@ -220,6 +264,57 @@ function routeManifest() {
|
|
|
220
264
|
}
|
|
221
265
|
return found;
|
|
222
266
|
};
|
|
267
|
+
/**
|
|
268
|
+
* Host middleware named by a route.ts beside or above a page.
|
|
269
|
+
*
|
|
270
|
+
* // app/admin/route.ts
|
|
271
|
+
* export const middleware = ['auth', 'can:update,post']
|
|
272
|
+
*
|
|
273
|
+
* Read statically rather than imported, for the same reason
|
|
274
|
+
* generateStaticParams is detected by reading the source: this runs while the
|
|
275
|
+
* manifest is being built, before there is a bundle to execute.
|
|
276
|
+
*
|
|
277
|
+
* The names mean nothing here. They are the host's vocabulary — Laravel
|
|
278
|
+
* middleware aliases, a Go router's names — and the engine only carries them
|
|
279
|
+
* to whoever knows what they mean.
|
|
280
|
+
*/
|
|
281
|
+
const middlewareIn = (absDir) => {
|
|
282
|
+
for (const file of ['route.ts', 'route.tsx']) {
|
|
283
|
+
const path = join(absDir, file);
|
|
284
|
+
if (!existsSync(path))
|
|
285
|
+
continue;
|
|
286
|
+
const match = readFileSync(path, 'utf-8').match(/export\s+const\s+middleware\s*(?::[^=]+)?=\s*\[([^\]]*)\]/);
|
|
287
|
+
if (!match)
|
|
288
|
+
continue;
|
|
289
|
+
// Each quoted literal, rather than splitting the list on commas: a
|
|
290
|
+
// middleware name carries its arguments after a colon and those are
|
|
291
|
+
// comma-separated too, so splitting turns 'throttle:60,1' into a
|
|
292
|
+
// throttle of 60 and a middleware called 1.
|
|
293
|
+
return [...match[1].matchAll(/['"`]([^'"`]*)['"`]/g)]
|
|
294
|
+
.map((quoted) => quoted[1].trim())
|
|
295
|
+
.filter(Boolean);
|
|
296
|
+
}
|
|
297
|
+
return [];
|
|
298
|
+
};
|
|
299
|
+
/**
|
|
300
|
+
* Every host middleware above and on a page, outermost first.
|
|
301
|
+
*
|
|
302
|
+
* Order is the whole of it: an outer guard has to run before an inner one, or
|
|
303
|
+
* a check deciding whether the inner check is even reachable runs second.
|
|
304
|
+
* Duplicates are dropped, so a name repeated down the tree runs once, at the
|
|
305
|
+
* outermost point it was asked for.
|
|
306
|
+
*/
|
|
307
|
+
const hostMiddleware = (dir) => {
|
|
308
|
+
const parts = dir.split('/').filter(Boolean);
|
|
309
|
+
const found = [];
|
|
310
|
+
for (let depth = 0; depth <= parts.length; depth++) {
|
|
311
|
+
for (const name of middlewareIn(join(sourceDir, ...parts.slice(0, depth)))) {
|
|
312
|
+
if (!found.includes(name))
|
|
313
|
+
found.push(name);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return found;
|
|
317
|
+
};
|
|
223
318
|
const routes = [];
|
|
224
319
|
const intercepts = [];
|
|
225
320
|
for (const name of names) {
|
|
@@ -252,6 +347,7 @@ function routeManifest() {
|
|
|
252
347
|
slots,
|
|
253
348
|
sections: names.filter((n) => SECTION_FILE.test(n + '.tsx') && dirOf(n) === dirOf(name)),
|
|
254
349
|
config: configIn(join(sourceDir, dirOf(name))),
|
|
350
|
+
hostMiddleware: hostMiddleware(dirOf(name)),
|
|
255
351
|
ancestorConfigs: ancestorConfigs(dirOf(name)),
|
|
256
352
|
staticParams: hasStaticParams(components.get(name).absPath),
|
|
257
353
|
clientJs: shipsClientJs(components.get(name).absPath),
|
|
@@ -337,6 +433,28 @@ declare module '*/dist/rsc/index.js' {
|
|
|
337
433
|
* render pipeline, and a dev server that never prerenders should not pay for
|
|
338
434
|
* loading it.
|
|
339
435
|
*/
|
|
436
|
+
/**
|
|
437
|
+
* What the build classified when it classified nothing.
|
|
438
|
+
*
|
|
439
|
+
* With prerendering off there is no probe and no answer to report, so every
|
|
440
|
+
* route is dynamic by construction rather than by measurement — printed the
|
|
441
|
+
* same way so the output means the same thing either way.
|
|
442
|
+
*/
|
|
443
|
+
function reportAllDynamic() {
|
|
444
|
+
const routes = routeManifest().routes;
|
|
445
|
+
for (const route of routes) {
|
|
446
|
+
// The pattern rather than a url: nothing was rendered, so there are no
|
|
447
|
+
// params and inventing one would name a page that may not exist.
|
|
448
|
+
const path = route.segments
|
|
449
|
+
.map((segment) => (segment.type === 'static' ? segment.value : `[${segment.value}]`))
|
|
450
|
+
.join('/');
|
|
451
|
+
console.log(` \u0192 /${path}`);
|
|
452
|
+
}
|
|
453
|
+
console.log(`
|
|
454
|
+
\u0192 (Dynamic) server-rendered on demand
|
|
455
|
+
|
|
456
|
+
${routes.length} dynamic — prerendering is off`);
|
|
457
|
+
}
|
|
340
458
|
async function prerenderAfterBundles() {
|
|
341
459
|
const bundle = join(outDir, 'dist/rsc/index.js');
|
|
342
460
|
if (!existsSync(bundle))
|
|
@@ -637,6 +755,16 @@ export async function getStaticParams(component: string): Promise<Record<string,
|
|
|
637
755
|
// not reach the Flight render.
|
|
638
756
|
const HOST_GLOBAL = ${JSON.stringify(hostGlobal)}
|
|
639
757
|
|
|
758
|
+
/**
|
|
759
|
+
* The reserved name a host answers route middleware on.
|
|
760
|
+
*
|
|
761
|
+
* Prefixed so it cannot collide with a function an application registered.
|
|
762
|
+
* A host that has never heard of it answers "no such function", which
|
|
763
|
+
* throws — the correct answer for a guarded route on a host that cannot
|
|
764
|
+
* check the guard.
|
|
765
|
+
*/
|
|
766
|
+
const HOST_MIDDLEWARE_FN = '__rsc.middleware'
|
|
767
|
+
|
|
640
768
|
let currentHost: HostFn | null = null
|
|
641
769
|
|
|
642
770
|
export function installHostFn(fn: HostFn) {
|
|
@@ -663,8 +791,33 @@ const probeHost = new AsyncLocalStorage<(...args: unknown[]) => Promise<unknown>
|
|
|
663
791
|
function applyHost() {
|
|
664
792
|
// A dispatcher, installed once. App code calls a global; which implementation
|
|
665
793
|
// that reaches is a question about the render it is inside.
|
|
666
|
-
|
|
667
|
-
|
|
794
|
+
//
|
|
795
|
+
;(globalThis as Record<string, unknown>)[HOST_GLOBAL] = (...args: unknown[]) => {
|
|
796
|
+
const fn = probeHost.getStore() ?? currentHost
|
|
797
|
+
|
|
798
|
+
// An optional call was here, and it answered every rpc() with undefined
|
|
799
|
+
// when no host was installed. undefined is a value: the component renders
|
|
800
|
+
// with it, the render succeeds, and a prerender freezes the result. The
|
|
801
|
+
// page then hydrates against an undefined prop, the client component reads
|
|
802
|
+
// a property of it, React unmounts the document, and the browser shows a
|
|
803
|
+
// blank page with nothing in the console.
|
|
804
|
+
//
|
|
805
|
+
// No backticks in this region — everything from the generated entry
|
|
806
|
+
// onward is a template literal, and one ends it here.
|
|
807
|
+
// Rejected rather than thrown: rpc() is documented to return a promise, so
|
|
808
|
+
// a caller that stores it before awaiting must get a rejection, not an
|
|
809
|
+
// exception from the call itself.
|
|
810
|
+
if (!fn) {
|
|
811
|
+
return Promise.reject(
|
|
812
|
+
new Error(
|
|
813
|
+
'No host callable is installed, so ' + String(args[0]) + ' cannot be answered. ' +
|
|
814
|
+
'A render that needs the host must either run with one installed or be probed.',
|
|
815
|
+
),
|
|
816
|
+
)
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
return fn(...args)
|
|
820
|
+
}
|
|
668
821
|
}
|
|
669
822
|
|
|
670
823
|
/**
|
|
@@ -907,7 +1060,55 @@ function segmentStart(
|
|
|
907
1060
|
*/
|
|
908
1061
|
let middlewareChains: Record<string, string[]> | null = null
|
|
909
1062
|
|
|
1063
|
+
let hostChains: Record<string, string[]> | null = null
|
|
1064
|
+
|
|
1065
|
+
/**
|
|
1066
|
+
* Guards the host runs, named by a route.ts and meaningless here.
|
|
1067
|
+
*
|
|
1068
|
+
* Asked before the engine's own middleware.ts guards, because a host's are the
|
|
1069
|
+
* coarser check — a session, a rate limit — and running application code to
|
|
1070
|
+
* decide whether application code may run is the wrong way round.
|
|
1071
|
+
*
|
|
1072
|
+
* It fails CLOSED, and that is the whole of its design. This call reaches
|
|
1073
|
+
* another process over a network, so it can time out, be refused, or answer
|
|
1074
|
+
* something unparseable — and every one of those is a guarded page rendered to
|
|
1075
|
+
* whoever asked, if the absence of a refusal is read as permission. Only a
|
|
1076
|
+
* literal true allows.
|
|
1077
|
+
*/
|
|
1078
|
+
async function runHostMiddleware(component) {
|
|
1079
|
+
if (!hostChains) {
|
|
1080
|
+
hostChains = {}
|
|
1081
|
+
|
|
1082
|
+
for (const route of manifest().routes) {
|
|
1083
|
+
if (route.hostMiddleware?.length) hostChains[route.component] = route.hostMiddleware
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
const names = hostChains[component] ?? []
|
|
1088
|
+
|
|
1089
|
+
if (names.length === 0) return
|
|
1090
|
+
|
|
1091
|
+
if (!currentHost) {
|
|
1092
|
+
throw new Error(
|
|
1093
|
+
'Route ' + component + ' declares host middleware (' + names.join(', ') +
|
|
1094
|
+
') but no host callable is installed, so it cannot be checked.',
|
|
1095
|
+
)
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
const answer = await currentHost(HOST_MIDDLEWARE_FN, names)
|
|
1099
|
+
|
|
1100
|
+
// Anything other than a literal true. A host answering null, undefined, a
|
|
1101
|
+
// string, or an object it happened to build on the way to an error is not
|
|
1102
|
+
// saying yes.
|
|
1103
|
+
if (answer !== true) {
|
|
1104
|
+
throw new Error('Host middleware refused ' + component + ' (' + names.join(', ') + ').')
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
|
|
910
1109
|
async function runMiddleware(component: string, props: Record<string, unknown> = {}): Promise<void> {
|
|
1110
|
+
await runHostMiddleware(component)
|
|
1111
|
+
|
|
911
1112
|
// Read from the route table rather than passed in, so every render path is
|
|
912
1113
|
// covered by construction and no host has to remember to forward them.
|
|
913
1114
|
if (!middlewareChains) {
|
|
@@ -1308,14 +1509,38 @@ export async function handleRsc(
|
|
|
1308
1509
|
from = 0,
|
|
1309
1510
|
pageKey = '',
|
|
1310
1511
|
bootstrap = true,
|
|
1512
|
+
canReachHost = true,
|
|
1311
1513
|
): Promise<{ body: string; rscPayload: string; clientChunks: unknown; usedDynamicApis: boolean; clientComponents: string[] }> {
|
|
1312
1514
|
applyHost()
|
|
1515
|
+
|
|
1516
|
+
// A build renders this with no host installed, so every rpc() has to suspend
|
|
1517
|
+
// rather than answer — which is what marks the page as needing a request.
|
|
1518
|
+
// Without the probe those calls found no host at all, and the page was
|
|
1519
|
+
// frozen holding whatever undefined rendered to.
|
|
1520
|
+
//
|
|
1521
|
+
// Defaults to true because the other caller is an interception, which runs
|
|
1522
|
+
// at request time with a real host and must not be probed.
|
|
1523
|
+
let usedDynamicApis = false
|
|
1524
|
+
|
|
1525
|
+
const probe = (..._args: unknown[]) => {
|
|
1526
|
+
usedDynamicApis = true
|
|
1527
|
+
|
|
1528
|
+
return new Promise<never>(() => {})
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
// Not a generic arrow function: this file is generated as .tsx, where <T>
|
|
1532
|
+
// parses as JSX and the build fails on a tag it cannot close.
|
|
1533
|
+
const runWith = canReachHost
|
|
1534
|
+
? (fn: () => Promise<unknown>) => fn()
|
|
1535
|
+
: (fn: () => Promise<unknown>) => probeHost.run(probe, fn)
|
|
1536
|
+
|
|
1313
1537
|
// renderTree (not bare buildElement) so the prerendered Flight payload carries
|
|
1314
1538
|
// the same <title>/<meta> elements the live SPA payload does.
|
|
1315
|
-
const
|
|
1316
|
-
|
|
1317
|
-
{ onError: flightOnError },
|
|
1539
|
+
const tree = await runWith(() =>
|
|
1540
|
+
renderTree(component, props, layouts, loadings, parallelSlots, {}, from, pageKey, bootstrap),
|
|
1318
1541
|
)
|
|
1542
|
+
|
|
1543
|
+
const flight = renderToReadableStream(tree, { onError: flightOnError })
|
|
1319
1544
|
const [forHtml, forPayload] = flight.tee()
|
|
1320
1545
|
const rscPayload = await new Response(forPayload).text()
|
|
1321
1546
|
const ssr = await (import.meta as any).viteRsc.loadModule('ssr', 'index')
|
|
@@ -1326,7 +1551,7 @@ export async function handleRsc(
|
|
|
1326
1551
|
body,
|
|
1327
1552
|
rscPayload,
|
|
1328
1553
|
clientChunks: {},
|
|
1329
|
-
usedDynamicApis
|
|
1554
|
+
usedDynamicApis,
|
|
1330
1555
|
// Client reference rows name the components the browser has to run. Shipping
|
|
1331
1556
|
// no runtime would leave them as inert markup, so the host refuses — and
|
|
1332
1557
|
// says which components forced the decision, since they are usually in a
|
|
@@ -1428,6 +1653,21 @@ export async function handleRscPprShell(
|
|
|
1428
1653
|
// paints immediately: giving that the same budget spends two seconds per
|
|
1429
1654
|
// route to learn nothing the first millisecond did not say.
|
|
1430
1655
|
budgetMs = PPR_SHELL_TIMEOUT_MS,
|
|
1656
|
+
// Whether this build can answer host calls.
|
|
1657
|
+
//
|
|
1658
|
+
// False is the old and still the usual answer: the stub suspends, and a page
|
|
1659
|
+
// reaching for its host becomes a shell rather than freezing data fetched
|
|
1660
|
+
// once at build time.
|
|
1661
|
+
//
|
|
1662
|
+
// A host that opened a callback socket for the build says true, and then the
|
|
1663
|
+
// call is made and its answer stored. That is the only way a Laravel page can
|
|
1664
|
+
// be static at all, since a host call is the only route its data has — and
|
|
1665
|
+
// connection() is how such a page opts back out.
|
|
1666
|
+
//
|
|
1667
|
+
// Asked of the caller rather than read from whatever host happens to be
|
|
1668
|
+
// installed, because those are different statements: a test that installed
|
|
1669
|
+
// one is not a build that can reach PHP.
|
|
1670
|
+
canReachHost = false,
|
|
1431
1671
|
): Promise<{ shellHtml: string; clientChunks: unknown; timedOut: boolean; usedDynamicApis: boolean; error?: string }> {
|
|
1432
1672
|
// Deliberately no middleware here. The probe is asking whether the content is
|
|
1433
1673
|
// the same for everyone, which is a question about the page. Whether a
|
|
@@ -1451,16 +1691,49 @@ export async function handleRscPprShell(
|
|
|
1451
1691
|
let error: string | undefined
|
|
1452
1692
|
let postponed: unknown = null
|
|
1453
1693
|
|
|
1454
|
-
// The budget
|
|
1455
|
-
// when it is aborted, handing back both what flushed and where it stopped, so
|
|
1456
|
-
// there is nothing left to cancel by hand and no window in which the render is
|
|
1457
|
-
// still going after the caller has moved on.
|
|
1694
|
+
// The budget, declared before the error handler that consults it.
|
|
1458
1695
|
const controller = new AbortController()
|
|
1459
1696
|
const budget = setTimeout(() => controller.abort(), budgetMs)
|
|
1460
1697
|
|
|
1698
|
+
// Anything that failed while producing this shell.
|
|
1699
|
+
//
|
|
1700
|
+
// A rejection inside a Suspense boundary does NOT reach the caller: React
|
|
1701
|
+
// catches it, keeps the fallback, and the render goes on to finish. So the
|
|
1702
|
+
// probe looked like a page that had nothing left to do — no postponed state,
|
|
1703
|
+
// no thrown error — and the fallback was frozen as a finished static page.
|
|
1704
|
+
// A database the build machine cannot reach produced a permanently loading
|
|
1705
|
+
// page, stored, with the build reporting success.
|
|
1706
|
+
let renderFailure: string | undefined
|
|
1707
|
+
|
|
1708
|
+
const noteFailure = (e: unknown): string | undefined => {
|
|
1709
|
+
const digest = redirectDigest(e)
|
|
1710
|
+
|
|
1711
|
+
// A redirect is a classification here, not a failure.
|
|
1712
|
+
if (digest) return digest
|
|
1713
|
+
|
|
1714
|
+
// Every probe ends by aborting, so React reports that abort. Asked of the
|
|
1715
|
+
// signal rather than matched against the message: a string test would be a
|
|
1716
|
+
// guess about wording, and would quietly stop working when React changed it.
|
|
1717
|
+
if (controller.signal.aborted) return undefined
|
|
1718
|
+
|
|
1719
|
+
renderFailure ??= e instanceof Error ? e.message : String(e)
|
|
1720
|
+
console.error('[rsc-routes]', e)
|
|
1721
|
+
|
|
1722
|
+
return undefined
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
// The budget is an abort signal rather than a race. \`prerender\` resolves when
|
|
1726
|
+
// it is aborted, handing back both what flushed and where it stopped, so there
|
|
1727
|
+
// is nothing left to cancel by hand and no window in which the render is still
|
|
1728
|
+
// going after the caller has moved on.
|
|
1729
|
+
|
|
1461
1730
|
// Everything the render does happens inside the scope, so the stand-in host
|
|
1462
1731
|
// travels with it rather than with the process.
|
|
1463
|
-
const
|
|
1732
|
+
const runWith = canReachHost
|
|
1733
|
+
? (fn: () => Promise<void>) => fn()
|
|
1734
|
+
: (fn: () => Promise<void>) => probeHost.run(probe, fn)
|
|
1735
|
+
|
|
1736
|
+
const produce = runWith(async () => {
|
|
1464
1737
|
try {
|
|
1465
1738
|
// Params settle only when this probe is for one concrete url. A route
|
|
1466
1739
|
// that listed its urls is being rendered for one of them, so the page
|
|
@@ -1480,14 +1753,17 @@ export async function handleRscPprShell(
|
|
|
1480
1753
|
)
|
|
1481
1754
|
// Quiet about a redirect: during the probe it is a classification, not
|
|
1482
1755
|
// a failure, and React would otherwise print a stack for every one.
|
|
1483
|
-
const flight = renderToReadableStream(tree, { onError:
|
|
1756
|
+
const flight = renderToReadableStream(tree, { onError: noteFailure })
|
|
1484
1757
|
const ssr = await (import.meta as any).viteRsc.loadModule('ssr', 'index')
|
|
1485
1758
|
// The flight stream stays open across this. A Flight stream that has
|
|
1486
1759
|
// closed tells the decoder the connection ended, so the boundary waiting
|
|
1487
1760
|
// on it errors rather than staying pending — and an errored boundary is
|
|
1488
1761
|
// finished, not postponed: it comes back null and there is nothing left
|
|
1489
1762
|
// to resume from.
|
|
1490
|
-
const prerendered = await ssr.handleSsrPrerender(flight, {
|
|
1763
|
+
const prerendered = await ssr.handleSsrPrerender(flight, {
|
|
1764
|
+
signal: controller.signal,
|
|
1765
|
+
onError: noteFailure,
|
|
1766
|
+
})
|
|
1491
1767
|
|
|
1492
1768
|
postponed = prerendered.postponed
|
|
1493
1769
|
|
|
@@ -1520,6 +1796,7 @@ export async function handleRscPprShell(
|
|
|
1520
1796
|
usedDynamicApis,
|
|
1521
1797
|
error,
|
|
1522
1798
|
postponed,
|
|
1799
|
+
renderFailure,
|
|
1523
1800
|
}
|
|
1524
1801
|
}
|
|
1525
1802
|
|
|
@@ -1881,9 +2158,38 @@ export function rscRoutes(options = {}) {
|
|
|
1881
2158
|
define: {
|
|
1882
2159
|
'process.env.NODE_ENV': JSON.stringify(env.mode === 'development' ? 'development' : 'production'),
|
|
1883
2160
|
},
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
2161
|
+
/*
|
|
2162
|
+
* This package's client modules are served as source, never
|
|
2163
|
+
* pre-bundled.
|
|
2164
|
+
*
|
|
2165
|
+
* Vite treats an installed package as a dependency and optimizes it,
|
|
2166
|
+
* which binds its JSX imports to one particular optimized
|
|
2167
|
+
* react/jsx-runtime chunk. The moment Vite discovers another dependency
|
|
2168
|
+
* and re-optimizes, that chunk's hash changes and the binding breaks:
|
|
2169
|
+
*
|
|
2170
|
+
* SyntaxError: The requested module '.../react_jsx-runtime.js?v=...'
|
|
2171
|
+
* does not provide an export named 't'
|
|
2172
|
+
*
|
|
2173
|
+
* Which reads as a React or a bundler bug. What it does is take down
|
|
2174
|
+
* every page importing Link or Form — the shell renders, hydration
|
|
2175
|
+
* throws, React unmounts the document, and the page goes blank with
|
|
2176
|
+
* that message the only clue.
|
|
2177
|
+
*
|
|
2178
|
+
* Excluded rather than pinned, because the package already ships ESM
|
|
2179
|
+
* that needs no conversion. The app's own excludes are kept.
|
|
2180
|
+
*/
|
|
2181
|
+
optimizeDeps: {
|
|
2182
|
+
exclude: [PACKAGE_NAME, ...(_config.optimizeDeps?.exclude ?? [])],
|
|
2183
|
+
},
|
|
2184
|
+
// Public URL for browser-facing client assets, and a BUILD concern
|
|
2185
|
+
// only: it says where the built files will be served from.
|
|
2186
|
+
//
|
|
2187
|
+
// Applying it in dev makes it Vite's public base, and then the dev
|
|
2188
|
+
// server answers pages only under that prefix — every route 404s with
|
|
2189
|
+
// "The server is configured with a public base URL", which reads as a
|
|
2190
|
+
// routing bug rather than as this line. In dev the pages are the root;
|
|
2191
|
+
// the assets come from the same origin either way.
|
|
2192
|
+
base: env.command === 'build' ? assetsBaseUrl : '/',
|
|
1887
2193
|
root: outDir,
|
|
1888
2194
|
// Force single instances of React/RSC runtime — critical when the
|
|
1889
2195
|
// package is symlinked (local dev / monorepo), else "use client"
|
|
@@ -1930,6 +2236,73 @@ export function rscRoutes(options = {}) {
|
|
|
1930
2236
|
* nothing.
|
|
1931
2237
|
*/
|
|
1932
2238
|
configureServer(server) {
|
|
2239
|
+
// rpc() has to reach the backend while the dev server is serving.
|
|
2240
|
+
//
|
|
2241
|
+
// A built deployment installs this itself: the server running
|
|
2242
|
+
// createRscHandler passes `hostCalls`. Nothing does that here, and with
|
|
2243
|
+
// no host installed every rpc() is refused — so a page whose data comes
|
|
2244
|
+
// from the backend renders its shell and then blanks, which reads as a
|
|
2245
|
+
// hydration bug rather than a missing wire.
|
|
2246
|
+
server.httpServer?.once('listening', async () => {
|
|
2247
|
+
const env = server.environments?.rsc;
|
|
2248
|
+
if (!env?.runner)
|
|
2249
|
+
return;
|
|
2250
|
+
// The app's own .env, unprefixed. A Laravel app already has both of
|
|
2251
|
+
// these, which is what makes this need no configuring: APP_URL is the
|
|
2252
|
+
// backend and RSC_HOST_CALL_SECRET is the secret it checks.
|
|
2253
|
+
const fromEnv = loadEnv(server.config.mode, projectRoot, '');
|
|
2254
|
+
const secret = hostCallOptions?.secret ?? fromEnv.RSC_HOST_CALL_SECRET;
|
|
2255
|
+
const origin = hostCallOptions?.endpoint ?? fromEnv.RSC_BACKEND ?? fromEnv.APP_URL;
|
|
2256
|
+
if (!secret || !origin)
|
|
2257
|
+
return;
|
|
2258
|
+
const path = hostCallOptions?.path ?? fromEnv.RSC_HOST_CALL_PATH ?? '/__rsc/host-call';
|
|
2259
|
+
const endpoint = origin.replace(/\/$/, '') + path;
|
|
2260
|
+
try {
|
|
2261
|
+
const entry = await env.runner.import(join(genDir, 'entry.rsc.tsx'));
|
|
2262
|
+
const install = entry.installHostFn;
|
|
2263
|
+
install?.(httpHostCalls({ endpoint, secret }));
|
|
2264
|
+
}
|
|
2265
|
+
catch (error) {
|
|
2266
|
+
// Reported rather than thrown: the dev server is still useful for
|
|
2267
|
+
// every page that needs no data, and a failure here would otherwise
|
|
2268
|
+
// look like the server refusing to start.
|
|
2269
|
+
server.config.logger.warn(`[rsc-routes] could not wire host calls to ${endpoint}: ` +
|
|
2270
|
+
(error instanceof Error ? error.message : String(error)));
|
|
2271
|
+
}
|
|
2272
|
+
});
|
|
2273
|
+
// Written once the server is listening, because only then is the port
|
|
2274
|
+
// known. Removed on shutdown so a backend can tell a dev server that is
|
|
2275
|
+
// gone from one that is merely slow to answer.
|
|
2276
|
+
if (hotFile) {
|
|
2277
|
+
const remove = () => {
|
|
2278
|
+
try {
|
|
2279
|
+
if (existsSync(hotFile))
|
|
2280
|
+
rmSync(hotFile);
|
|
2281
|
+
}
|
|
2282
|
+
catch { }
|
|
2283
|
+
};
|
|
2284
|
+
server.httpServer?.once('listening', () => {
|
|
2285
|
+
// The url Vite resolved, not one built from the port. A dev server
|
|
2286
|
+
// whose port is already taken on IPv4 binds IPv6 only and keeps the
|
|
2287
|
+
// number — so http://127.0.0.1:<port> is a reachable-looking address
|
|
2288
|
+
// that nothing answers, and the backend reports the renderer as down
|
|
2289
|
+
// while it is plainly running.
|
|
2290
|
+
const resolved = server.resolvedUrls?.local?.[0];
|
|
2291
|
+
const address = server.httpServer?.address();
|
|
2292
|
+
const url = resolved ??
|
|
2293
|
+
(typeof address === 'object' && address
|
|
2294
|
+
? `http://${address.family === 'IPv6' ? `[${address.address}]` : address.address}:${address.port}`
|
|
2295
|
+
: null);
|
|
2296
|
+
if (!url)
|
|
2297
|
+
return;
|
|
2298
|
+
mkdirSync(dirname(hotFile), { recursive: true });
|
|
2299
|
+
writeFileSync(hotFile, url.replace(/\/$/, ''));
|
|
2300
|
+
});
|
|
2301
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'exit']) {
|
|
2302
|
+
process.once(signal, remove);
|
|
2303
|
+
}
|
|
2304
|
+
server.httpServer?.once('close', remove);
|
|
2305
|
+
}
|
|
1933
2306
|
const shapes = new Set(ROUTE_FILES.map((name) => name));
|
|
1934
2307
|
const affectsRouting = (file) => {
|
|
1935
2308
|
if (!file.startsWith(sourceDir))
|
|
@@ -1972,8 +2345,19 @@ export function rscRoutes(options = {}) {
|
|
|
1972
2345
|
* every route is not a feedback loop anyone wants.
|
|
1973
2346
|
*/
|
|
1974
2347
|
async buildApp() {
|
|
1975
|
-
if (
|
|
2348
|
+
if (isWatch)
|
|
2349
|
+
return;
|
|
2350
|
+
// Say what the build did, even when it stored nothing.
|
|
2351
|
+
//
|
|
2352
|
+
// Turning prerendering off used to print no classification at all — no
|
|
2353
|
+
// marks, no legend, no counts — so a build that stored nothing looked
|
|
2354
|
+
// exactly like a build that had not got to that step. Every route renders
|
|
2355
|
+
// per visitor now, which is a thing worth being told rather than left to
|
|
2356
|
+
// infer from an absence.
|
|
2357
|
+
if (!prerenderAfterBuild) {
|
|
2358
|
+
reportAllDynamic();
|
|
1976
2359
|
return;
|
|
2360
|
+
}
|
|
1977
2361
|
await prerenderAfterBundles();
|
|
1978
2362
|
},
|
|
1979
2363
|
configResolved(config) {
|