@rsc-kit/core 0.3.0 → 0.5.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/prerender.d.ts +10 -1
- package/dist/prerender.d.ts.map +1 -1
- package/dist/prerender.js +12 -1
- package/dist/prerender.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 +43 -0
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +400 -18
- 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 -944
- 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),
|
|
@@ -568,7 +664,7 @@ function hasStaticParams(absPath) {
|
|
|
568
664
|
return /export\s+((async\s+)?function\s+generateStaticParams|const\s+generateStaticParams)/.test(src);
|
|
569
665
|
}
|
|
570
666
|
// ── Codegen ──────────────────────────────────────────────────────────────────
|
|
571
|
-
function generateEntryRsc() {
|
|
667
|
+
function generateEntryRsc(fallbackOrigin = '') {
|
|
572
668
|
const imports = [];
|
|
573
669
|
const mapEntries = [];
|
|
574
670
|
const metaEntries = [];
|
|
@@ -659,6 +755,16 @@ export async function getStaticParams(component: string): Promise<Record<string,
|
|
|
659
755
|
// not reach the Flight render.
|
|
660
756
|
const HOST_GLOBAL = ${JSON.stringify(hostGlobal)}
|
|
661
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
|
+
|
|
662
768
|
let currentHost: HostFn | null = null
|
|
663
769
|
|
|
664
770
|
export function installHostFn(fn: HostFn) {
|
|
@@ -686,8 +792,32 @@ function applyHost() {
|
|
|
686
792
|
// A dispatcher, installed once. App code calls a global; which implementation
|
|
687
793
|
// that reaches is a question about the render it is inside.
|
|
688
794
|
//
|
|
689
|
-
;(globalThis as Record<string, unknown>)[HOST_GLOBAL] = (...args: unknown[]) =>
|
|
690
|
-
|
|
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
|
+
}
|
|
691
821
|
}
|
|
692
822
|
|
|
693
823
|
/**
|
|
@@ -930,7 +1060,55 @@ function segmentStart(
|
|
|
930
1060
|
*/
|
|
931
1061
|
let middlewareChains: Record<string, string[]> | null = null
|
|
932
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
|
+
|
|
933
1109
|
async function runMiddleware(component: string, props: Record<string, unknown> = {}): Promise<void> {
|
|
1110
|
+
await runHostMiddleware(component)
|
|
1111
|
+
|
|
934
1112
|
// Read from the route table rather than passed in, so every render path is
|
|
935
1113
|
// covered by construction and no host has to remember to forward them.
|
|
936
1114
|
if (!middlewareChains) {
|
|
@@ -1331,14 +1509,38 @@ export async function handleRsc(
|
|
|
1331
1509
|
from = 0,
|
|
1332
1510
|
pageKey = '',
|
|
1333
1511
|
bootstrap = true,
|
|
1512
|
+
canReachHost = true,
|
|
1334
1513
|
): Promise<{ body: string; rscPayload: string; clientChunks: unknown; usedDynamicApis: boolean; clientComponents: string[] }> {
|
|
1335
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
|
+
|
|
1336
1537
|
// renderTree (not bare buildElement) so the prerendered Flight payload carries
|
|
1337
1538
|
// the same <title>/<meta> elements the live SPA payload does.
|
|
1338
|
-
const
|
|
1339
|
-
|
|
1340
|
-
{ onError: flightOnError },
|
|
1539
|
+
const tree = await runWith(() =>
|
|
1540
|
+
renderTree(component, props, layouts, loadings, parallelSlots, {}, from, pageKey, bootstrap),
|
|
1341
1541
|
)
|
|
1542
|
+
|
|
1543
|
+
const flight = renderToReadableStream(tree, { onError: flightOnError })
|
|
1342
1544
|
const [forHtml, forPayload] = flight.tee()
|
|
1343
1545
|
const rscPayload = await new Response(forPayload).text()
|
|
1344
1546
|
const ssr = await (import.meta as any).viteRsc.loadModule('ssr', 'index')
|
|
@@ -1349,7 +1551,7 @@ export async function handleRsc(
|
|
|
1349
1551
|
body,
|
|
1350
1552
|
rscPayload,
|
|
1351
1553
|
clientChunks: {},
|
|
1352
|
-
usedDynamicApis
|
|
1554
|
+
usedDynamicApis,
|
|
1353
1555
|
// Client reference rows name the components the browser has to run. Shipping
|
|
1354
1556
|
// no runtime would leave them as inert markup, so the host refuses — and
|
|
1355
1557
|
// says which components forced the decision, since they are usually in a
|
|
@@ -1612,6 +1814,10 @@ export async function handleRscPprShell(
|
|
|
1612
1814
|
* assets in dev, and a frozen page is a build artifact: serving one here would
|
|
1613
1815
|
* hand back the last build's HTML for a file just edited.
|
|
1614
1816
|
*/
|
|
1817
|
+
const FALLBACK_ORIGIN = ${JSON.stringify(fallbackOrigin)}
|
|
1818
|
+
const FALLBACK_MARKER = 'x-rsc-renderer-fallback'
|
|
1819
|
+
const PROXIED_MARKER = 'x-rsc-proxied-by-backend'
|
|
1820
|
+
|
|
1615
1821
|
let devHandler: ((request: Request) => Promise<Response | null>) | null = null
|
|
1616
1822
|
|
|
1617
1823
|
export default async function handler(request: Request): Promise<Response> {
|
|
@@ -1633,7 +1839,75 @@ export default async function handler(request: Request): Promise<Response> {
|
|
|
1633
1839
|
} as never,
|
|
1634
1840
|
})
|
|
1635
1841
|
|
|
1636
|
-
|
|
1842
|
+
const answer = await devHandler(request)
|
|
1843
|
+
|
|
1844
|
+
if (answer) return answer
|
|
1845
|
+
|
|
1846
|
+
// Nothing here owns this url. In development the backend usually does — a
|
|
1847
|
+
// Blade page, /login, a webhook, an uploaded file under /storage — so the
|
|
1848
|
+
// request is handed on rather than refused, and this origin is the whole
|
|
1849
|
+
// application instead of the RSC half of it.
|
|
1850
|
+
//
|
|
1851
|
+
// FALLBACK_MARKER is what stops this looping. The backend's own fallback
|
|
1852
|
+
// forwards what it cannot route BACK to this server, so without a marker a
|
|
1853
|
+
// url neither side owns would bounce between them until something gave out.
|
|
1854
|
+
// Seeing it, the backend answers 404 itself.
|
|
1855
|
+
// Came from the backend's own proxy, so it has already been through that
|
|
1856
|
+
// route table and the answer there was no. Sending it back asks the same
|
|
1857
|
+
// question a second time.
|
|
1858
|
+
if (!FALLBACK_ORIGIN || request.headers.has(PROXIED_MARKER)) {
|
|
1859
|
+
return new Response('Not found', { status: 404 })
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
// Built from the origin rather than by assigning onto a copy of this url.
|
|
1863
|
+
// The URL host setter keeps whatever port is already there when the value it
|
|
1864
|
+
// is given has none, so a portless backend — every Herd or Valet site —
|
|
1865
|
+
// would inherit the dev server's own port and this server would call itself.
|
|
1866
|
+
const here = new URL(request.url)
|
|
1867
|
+
const target = new URL(here.pathname + here.search, FALLBACK_ORIGIN)
|
|
1868
|
+
|
|
1869
|
+
const headers = new Headers(request.headers)
|
|
1870
|
+
|
|
1871
|
+
// Never forwarded: a vhost server routes on it, so telling Herd the host is
|
|
1872
|
+
// localhost:5173 means it has no such site and answers 404. fetch sets it
|
|
1873
|
+
// from the target instead.
|
|
1874
|
+
headers.delete('host')
|
|
1875
|
+
headers.set(FALLBACK_MARKER, '1')
|
|
1876
|
+
|
|
1877
|
+
// What the browser actually asked for. Without these the backend generates
|
|
1878
|
+
// absolute urls — url(), route(), redirects, form actions — against its own
|
|
1879
|
+
// origin rather than this one, and a redirect walks the browser off this
|
|
1880
|
+
// server onto the backend.
|
|
1881
|
+
//
|
|
1882
|
+
// They only take effect if the backend trusts this proxy: Laravel needs the
|
|
1883
|
+
// renderer's address in trustProxies. Sent regardless, because a header an
|
|
1884
|
+
// untrusting backend ignores costs nothing, and the alternative is that
|
|
1885
|
+
// there is no way to get it right at all.
|
|
1886
|
+
headers.set('x-forwarded-host', here.host)
|
|
1887
|
+
headers.set('x-forwarded-proto', here.protocol.replace(':', ''))
|
|
1888
|
+
|
|
1889
|
+
const hasBody = request.method !== 'GET' && request.method !== 'HEAD'
|
|
1890
|
+
|
|
1891
|
+
try {
|
|
1892
|
+
return await fetch(target, {
|
|
1893
|
+
method: request.method,
|
|
1894
|
+
headers,
|
|
1895
|
+
body: hasBody ? request.body : undefined,
|
|
1896
|
+
// A redirect is the backend's answer and belongs to the browser.
|
|
1897
|
+
// Following it here would return the destination's body under this url.
|
|
1898
|
+
redirect: 'manual',
|
|
1899
|
+
...(hasBody ? { duplex: 'half' } : {}),
|
|
1900
|
+
} as RequestInit)
|
|
1901
|
+
} catch (error) {
|
|
1902
|
+
return new Response(
|
|
1903
|
+
// The cause, not just the wrapper: fetch reports every network failure
|
|
1904
|
+
// as the same 'TypeError: fetch failed', and the refused address
|
|
1905
|
+
// underneath it is the whole of the diagnosis.
|
|
1906
|
+
'The backend at ' + FALLBACK_ORIGIN + ' is not answering: ' +
|
|
1907
|
+
String((error as { cause?: unknown }).cause ?? error),
|
|
1908
|
+
{ status: 502 },
|
|
1909
|
+
)
|
|
1910
|
+
}
|
|
1637
1911
|
}
|
|
1638
1912
|
`;
|
|
1639
1913
|
}
|
|
@@ -1927,7 +2201,19 @@ export function rscRoutes(options = {}) {
|
|
|
1927
2201
|
if (existsSync(genDir))
|
|
1928
2202
|
rmSync(genDir, { recursive: true, force: true });
|
|
1929
2203
|
mkdirSync(genDir, { recursive: true });
|
|
1930
|
-
|
|
2204
|
+
// Where a url this server does not own is handed on. Resolved exactly as
|
|
2205
|
+
// host calls resolve their endpoint, from the app's own .env, so the two
|
|
2206
|
+
// cannot end up pointing at different backends. Development only: a
|
|
2207
|
+
// build's server.ts decides this for itself.
|
|
2208
|
+
const backendEnv = loadEnv(env.mode, projectRoot, '');
|
|
2209
|
+
const fallbackOrigin = options.devFallback === false
|
|
2210
|
+
? ''
|
|
2211
|
+
: (options.devFallback ??
|
|
2212
|
+
hostCallOptions?.endpoint ??
|
|
2213
|
+
backendEnv.RSC_BACKEND ??
|
|
2214
|
+
backendEnv.APP_URL ??
|
|
2215
|
+
'');
|
|
2216
|
+
writeFileSync(join(genDir, 'entry.rsc.tsx'), generateEntryRsc(fallbackOrigin));
|
|
1931
2217
|
writeFileSync(join(genDir, 'entry.ssr.tsx'), generateEntrySsr());
|
|
1932
2218
|
writeFileSync(join(genDir, 'entry.browser.tsx'), generateEntryBrowser());
|
|
1933
2219
|
// Written beside the entries, for a host to read instead of walking the
|
|
@@ -1956,9 +2242,38 @@ export function rscRoutes(options = {}) {
|
|
|
1956
2242
|
define: {
|
|
1957
2243
|
'process.env.NODE_ENV': JSON.stringify(env.mode === 'development' ? 'development' : 'production'),
|
|
1958
2244
|
},
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
2245
|
+
/*
|
|
2246
|
+
* This package's client modules are served as source, never
|
|
2247
|
+
* pre-bundled.
|
|
2248
|
+
*
|
|
2249
|
+
* Vite treats an installed package as a dependency and optimizes it,
|
|
2250
|
+
* which binds its JSX imports to one particular optimized
|
|
2251
|
+
* react/jsx-runtime chunk. The moment Vite discovers another dependency
|
|
2252
|
+
* and re-optimizes, that chunk's hash changes and the binding breaks:
|
|
2253
|
+
*
|
|
2254
|
+
* SyntaxError: The requested module '.../react_jsx-runtime.js?v=...'
|
|
2255
|
+
* does not provide an export named 't'
|
|
2256
|
+
*
|
|
2257
|
+
* Which reads as a React or a bundler bug. What it does is take down
|
|
2258
|
+
* every page importing Link or Form — the shell renders, hydration
|
|
2259
|
+
* throws, React unmounts the document, and the page goes blank with
|
|
2260
|
+
* that message the only clue.
|
|
2261
|
+
*
|
|
2262
|
+
* Excluded rather than pinned, because the package already ships ESM
|
|
2263
|
+
* that needs no conversion. The app's own excludes are kept.
|
|
2264
|
+
*/
|
|
2265
|
+
optimizeDeps: {
|
|
2266
|
+
exclude: [PACKAGE_NAME, ...(_config.optimizeDeps?.exclude ?? [])],
|
|
2267
|
+
},
|
|
2268
|
+
// Public URL for browser-facing client assets, and a BUILD concern
|
|
2269
|
+
// only: it says where the built files will be served from.
|
|
2270
|
+
//
|
|
2271
|
+
// Applying it in dev makes it Vite's public base, and then the dev
|
|
2272
|
+
// server answers pages only under that prefix — every route 404s with
|
|
2273
|
+
// "The server is configured with a public base URL", which reads as a
|
|
2274
|
+
// routing bug rather than as this line. In dev the pages are the root;
|
|
2275
|
+
// the assets come from the same origin either way.
|
|
2276
|
+
base: env.command === 'build' ? assetsBaseUrl : '/',
|
|
1962
2277
|
root: outDir,
|
|
1963
2278
|
// Force single instances of React/RSC runtime — critical when the
|
|
1964
2279
|
// package is symlinked (local dev / monorepo), else "use client"
|
|
@@ -2005,6 +2320,73 @@ export function rscRoutes(options = {}) {
|
|
|
2005
2320
|
* nothing.
|
|
2006
2321
|
*/
|
|
2007
2322
|
configureServer(server) {
|
|
2323
|
+
// rpc() has to reach the backend while the dev server is serving.
|
|
2324
|
+
//
|
|
2325
|
+
// A built deployment installs this itself: the server running
|
|
2326
|
+
// createRscHandler passes `hostCalls`. Nothing does that here, and with
|
|
2327
|
+
// no host installed every rpc() is refused — so a page whose data comes
|
|
2328
|
+
// from the backend renders its shell and then blanks, which reads as a
|
|
2329
|
+
// hydration bug rather than a missing wire.
|
|
2330
|
+
server.httpServer?.once('listening', async () => {
|
|
2331
|
+
const env = server.environments?.rsc;
|
|
2332
|
+
if (!env?.runner)
|
|
2333
|
+
return;
|
|
2334
|
+
// The app's own .env, unprefixed. A Laravel app already has both of
|
|
2335
|
+
// these, which is what makes this need no configuring: APP_URL is the
|
|
2336
|
+
// backend and RSC_HOST_CALL_SECRET is the secret it checks.
|
|
2337
|
+
const fromEnv = loadEnv(server.config.mode, projectRoot, '');
|
|
2338
|
+
const secret = hostCallOptions?.secret ?? fromEnv.RSC_HOST_CALL_SECRET;
|
|
2339
|
+
const origin = hostCallOptions?.endpoint ?? fromEnv.RSC_BACKEND ?? fromEnv.APP_URL;
|
|
2340
|
+
if (!secret || !origin)
|
|
2341
|
+
return;
|
|
2342
|
+
const path = hostCallOptions?.path ?? fromEnv.RSC_HOST_CALL_PATH ?? '/__rsc/host-call';
|
|
2343
|
+
const endpoint = origin.replace(/\/$/, '') + path;
|
|
2344
|
+
try {
|
|
2345
|
+
const entry = await env.runner.import(join(genDir, 'entry.rsc.tsx'));
|
|
2346
|
+
const install = entry.installHostFn;
|
|
2347
|
+
install?.(httpHostCalls({ endpoint, secret }));
|
|
2348
|
+
}
|
|
2349
|
+
catch (error) {
|
|
2350
|
+
// Reported rather than thrown: the dev server is still useful for
|
|
2351
|
+
// every page that needs no data, and a failure here would otherwise
|
|
2352
|
+
// look like the server refusing to start.
|
|
2353
|
+
server.config.logger.warn(`[rsc-routes] could not wire host calls to ${endpoint}: ` +
|
|
2354
|
+
(error instanceof Error ? error.message : String(error)));
|
|
2355
|
+
}
|
|
2356
|
+
});
|
|
2357
|
+
// Written once the server is listening, because only then is the port
|
|
2358
|
+
// known. Removed on shutdown so a backend can tell a dev server that is
|
|
2359
|
+
// gone from one that is merely slow to answer.
|
|
2360
|
+
if (hotFile) {
|
|
2361
|
+
const remove = () => {
|
|
2362
|
+
try {
|
|
2363
|
+
if (existsSync(hotFile))
|
|
2364
|
+
rmSync(hotFile);
|
|
2365
|
+
}
|
|
2366
|
+
catch { }
|
|
2367
|
+
};
|
|
2368
|
+
server.httpServer?.once('listening', () => {
|
|
2369
|
+
// The url Vite resolved, not one built from the port. A dev server
|
|
2370
|
+
// whose port is already taken on IPv4 binds IPv6 only and keeps the
|
|
2371
|
+
// number — so http://127.0.0.1:<port> is a reachable-looking address
|
|
2372
|
+
// that nothing answers, and the backend reports the renderer as down
|
|
2373
|
+
// while it is plainly running.
|
|
2374
|
+
const resolved = server.resolvedUrls?.local?.[0];
|
|
2375
|
+
const address = server.httpServer?.address();
|
|
2376
|
+
const url = resolved ??
|
|
2377
|
+
(typeof address === 'object' && address
|
|
2378
|
+
? `http://${address.family === 'IPv6' ? `[${address.address}]` : address.address}:${address.port}`
|
|
2379
|
+
: null);
|
|
2380
|
+
if (!url)
|
|
2381
|
+
return;
|
|
2382
|
+
mkdirSync(dirname(hotFile), { recursive: true });
|
|
2383
|
+
writeFileSync(hotFile, url.replace(/\/$/, ''));
|
|
2384
|
+
});
|
|
2385
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'exit']) {
|
|
2386
|
+
process.once(signal, remove);
|
|
2387
|
+
}
|
|
2388
|
+
server.httpServer?.once('close', remove);
|
|
2389
|
+
}
|
|
2008
2390
|
const shapes = new Set(ROUTE_FILES.map((name) => name));
|
|
2009
2391
|
const affectsRouting = (file) => {
|
|
2010
2392
|
if (!file.startsWith(sourceDir))
|