rsbuild-plugin-react-router 0.7.2 → 0.8.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/README.md +14 -2
- package/dist/468.js +48 -0
- package/dist/511.js +60 -86
- package/dist/819.js +64 -0
- package/dist/build-output-transforms.d.ts +3 -1
- package/dist/constants.d.ts +1 -0
- package/dist/dev-hdr-channel.d.ts +9 -0
- package/dist/dev-hmr.d.ts +1 -22
- package/dist/dev-runtime-controller.d.ts +1 -6
- package/dist/dev-server.d.ts +3 -1
- package/dist/index.cjs +5417 -5080
- package/dist/index.js +1701 -1441
- package/dist/manifest-assets.d.ts +34 -0
- package/dist/manifest-snapshot.d.ts +17 -0
- package/dist/manifest-state.d.ts +12 -0
- package/dist/manifest.d.ts +2 -22
- package/dist/modify-browser-manifest.d.ts +2 -0
- package/dist/node-only-manifest.d.ts +8 -0
- package/dist/plugin-utils.d.ts +1 -1
- package/dist/rsc-prerender.d.ts +3 -2
- package/dist/server-build-worker-client.d.ts +20 -0
- package/dist/server-build-worker-protocol.d.ts +84 -0
- package/dist/server-build-worker.d.ts +1 -0
- package/dist/server-build-worker.js +123 -0
- package/dist/server-utils.d.ts +1 -2
- package/dist/types.d.ts +6 -0
- package/package.json +4 -4
- package/src/build-output-transforms.ts +22 -1
- package/src/classic-mode.ts +0 -1
- package/src/constants.ts +3 -0
- package/src/dev-hdr-channel.ts +38 -0
- package/src/dev-hmr.ts +57 -100
- package/src/dev-runtime-controller.ts +23 -18
- package/src/dev-server.ts +24 -4
- package/src/index.ts +229 -144
- package/src/lazy-compilation.ts +7 -2
- package/src/manifest-assets.ts +228 -0
- package/src/manifest-snapshot.ts +80 -0
- package/src/manifest-state.ts +71 -0
- package/src/manifest.ts +23 -161
- package/src/mode-plan.ts +12 -4
- package/src/modify-browser-manifest.ts +47 -18
- package/src/node-only-manifest.ts +52 -0
- package/src/plugin-utils.ts +6 -2
- package/src/prerender-build.ts +76 -86
- package/src/route-chunks.ts +152 -63
- package/src/rsc-prerender.ts +7 -35
- package/src/server-build-resolution.ts +1 -2
- package/src/server-build-worker-client.ts +221 -0
- package/src/server-build-worker-protocol.ts +69 -0
- package/src/server-build-worker.ts +192 -0
- package/src/server-utils.ts +0 -2
- package/src/types.ts +7 -0
package/src/route-chunks.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
2
|
Analyzer,
|
|
3
|
+
SymbolFlags,
|
|
4
|
+
type Export as YukuExport,
|
|
3
5
|
type Module,
|
|
6
|
+
type Reference as YukuReference,
|
|
4
7
|
type Symbol as YukuSymbol,
|
|
5
8
|
} from 'yuku-analyzer';
|
|
6
9
|
import { print } from 'yuku-codegen';
|
|
7
|
-
import { walk } from 'yuku-parser';
|
|
10
|
+
import { walk, type Node } from 'yuku-parser';
|
|
8
11
|
import { dirname, normalize, relative, resolve } from 'pathe';
|
|
9
12
|
import { SERVER_ONLY_ROUTE_EXPORTS_SET } from './constants.js';
|
|
10
13
|
import { createRouteId } from './plugin-utils.js';
|
|
@@ -146,6 +149,7 @@ type ExportDependencies = {
|
|
|
146
149
|
importedIdentifierNames: Set<string>;
|
|
147
150
|
importSources: Set<string>;
|
|
148
151
|
exportedVariableDeclarators: Set<AnyNode>;
|
|
152
|
+
exportedLocalSymbols: Set<YukuSymbol>;
|
|
149
153
|
};
|
|
150
154
|
|
|
151
155
|
const getTopLevelStatementForNode = (
|
|
@@ -162,30 +166,30 @@ const getTopLevelStatementForNode = (
|
|
|
162
166
|
return current;
|
|
163
167
|
};
|
|
164
168
|
|
|
165
|
-
const
|
|
169
|
+
const getExportedVariableDeclaratorForNode = (
|
|
166
170
|
module: Module,
|
|
167
171
|
node: AnyNode
|
|
168
172
|
): AnyNode | null => {
|
|
169
|
-
let current
|
|
170
|
-
while (
|
|
171
|
-
|
|
172
|
-
|
|
173
|
+
let current = node as Node;
|
|
174
|
+
while (true) {
|
|
175
|
+
const parent = module.parentOf(current);
|
|
176
|
+
if (!parent || parent.type === 'Program') {
|
|
177
|
+
return null;
|
|
173
178
|
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
)
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
179
|
+
if (
|
|
180
|
+
current.type === 'VariableDeclarator' &&
|
|
181
|
+
parent.type === 'VariableDeclaration'
|
|
182
|
+
) {
|
|
183
|
+
const exported = module.parentOf(parent);
|
|
184
|
+
if (
|
|
185
|
+
exported?.type === 'ExportNamedDeclaration' &&
|
|
186
|
+
module.parentOf(exported)?.type === 'Program'
|
|
187
|
+
) {
|
|
188
|
+
return current;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
current = parent;
|
|
186
192
|
}
|
|
187
|
-
const statement = module.parentOf(declaration as never) as AnyNode | null;
|
|
188
|
-
return statement?.type === 'ExportNamedDeclaration';
|
|
189
193
|
};
|
|
190
194
|
|
|
191
195
|
const getExportedName = (exported: AnyNode): string => {
|
|
@@ -241,9 +245,29 @@ const getExportDependencies = (
|
|
|
241
245
|
code,
|
|
242
246
|
() => {
|
|
243
247
|
const { module } = analyzeCode(code, cache, cacheKey);
|
|
248
|
+
const namedExports = module.exports.filter(
|
|
249
|
+
(exp): exp is YukuExport & { name: string } =>
|
|
250
|
+
exp.name !== null &&
|
|
251
|
+
!exp.typeOnly &&
|
|
252
|
+
!exp.isStar &&
|
|
253
|
+
!exp.isExportEquals
|
|
254
|
+
);
|
|
255
|
+
// Removing type declarations can change legacy decorator metadata and
|
|
256
|
+
// name hygiene. Preserve the original dependency graph for decorated
|
|
257
|
+
// modules because the downstream compiler options are not known here.
|
|
258
|
+
let hasDecorators = false;
|
|
259
|
+
walk(module.ast, {
|
|
260
|
+
Decorator(_node, context) {
|
|
261
|
+
hasDecorators = true;
|
|
262
|
+
context.stop();
|
|
263
|
+
},
|
|
264
|
+
});
|
|
244
265
|
const exportDependencies = new Map<string, ExportDependencies>();
|
|
245
266
|
const topLevelStatementCache = new Map<AnyNode, AnyNode>();
|
|
246
|
-
const
|
|
267
|
+
const exportedVariableDeclaratorCache = new Map<
|
|
268
|
+
AnyNode,
|
|
269
|
+
AnyNode | null
|
|
270
|
+
>();
|
|
247
271
|
const getCachedTopLevelStatementForNode = (node: AnyNode): AnyNode => {
|
|
248
272
|
const cached = topLevelStatementCache.get(node);
|
|
249
273
|
if (cached) {
|
|
@@ -254,14 +278,67 @@ const getExportDependencies = (
|
|
|
254
278
|
return statement;
|
|
255
279
|
};
|
|
256
280
|
|
|
257
|
-
|
|
281
|
+
// Ordinary imports can be repeated in multiple chunks. Exported local
|
|
282
|
+
// bindings must keep a single owner, including functions and classes.
|
|
283
|
+
const nonShareableExportedSymbols = new Set<YukuSymbol>();
|
|
284
|
+
for (const { local } of namedExports) {
|
|
285
|
+
if (!local?.has(SymbolFlags.ValueSpace | SymbolFlags.ValueImport)) {
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
const isImport = local.declarations.every(
|
|
289
|
+
declaration =>
|
|
290
|
+
getCachedTopLevelStatementForNode(declaration).type ===
|
|
291
|
+
'ImportDeclaration'
|
|
292
|
+
);
|
|
293
|
+
// Setup belongs to the imported value too. If another export consumes
|
|
294
|
+
// that value, moving its setup into a separate chunk changes behavior.
|
|
295
|
+
const hasSetup =
|
|
296
|
+
isImport &&
|
|
297
|
+
local.references.some(reference => {
|
|
298
|
+
const statement = getCachedTopLevelStatementForNode(reference.node);
|
|
299
|
+
return (
|
|
300
|
+
reference.kind === 'value' &&
|
|
301
|
+
statement.type !== 'ImportDeclaration' &&
|
|
302
|
+
!statement.type.startsWith('Export')
|
|
303
|
+
);
|
|
304
|
+
});
|
|
305
|
+
if (isImport && !hasSetup) continue;
|
|
306
|
+
nonShareableExportedSymbols.add(local);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const isValueImportEqualsReference = (
|
|
310
|
+
reference: YukuReference
|
|
311
|
+
): boolean => {
|
|
312
|
+
let node: Node = reference.node;
|
|
313
|
+
let parent = module.parentOf(node);
|
|
314
|
+
while (parent?.type === 'TSQualifiedName') {
|
|
315
|
+
node = parent;
|
|
316
|
+
parent = module.parentOf(node);
|
|
317
|
+
}
|
|
318
|
+
// Yuku also marks the runtime RHS of `import x = Namespace.value`
|
|
319
|
+
// as a type reference.
|
|
320
|
+
return (
|
|
321
|
+
parent?.type === 'TSImportEqualsDeclaration' &&
|
|
322
|
+
parent.moduleReference === node &&
|
|
323
|
+
parent.importKind !== 'type'
|
|
324
|
+
);
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const isRuntimeRelevantReference = (reference: YukuReference): boolean =>
|
|
328
|
+
hasDecorators ||
|
|
329
|
+
reference.kind === 'value' ||
|
|
330
|
+
isValueImportEqualsReference(reference);
|
|
331
|
+
|
|
332
|
+
const getCachedExportedVariableDeclaratorForNode = (
|
|
258
333
|
node: AnyNode
|
|
259
334
|
): AnyNode | null => {
|
|
260
|
-
if (
|
|
261
|
-
return
|
|
335
|
+
if (exportedVariableDeclaratorCache.has(node)) {
|
|
336
|
+
return exportedVariableDeclaratorCache.get(node) ?? null;
|
|
262
337
|
}
|
|
263
|
-
|
|
264
|
-
|
|
338
|
+
// Only direct exported declarators can be emitted independently.
|
|
339
|
+
// Every other top-level statement is moved as a whole.
|
|
340
|
+
const declarator = getExportedVariableDeclaratorForNode(module, node);
|
|
341
|
+
exportedVariableDeclaratorCache.set(node, declarator);
|
|
265
342
|
return declarator;
|
|
266
343
|
};
|
|
267
344
|
|
|
@@ -291,22 +368,41 @@ const getExportDependencies = (
|
|
|
291
368
|
importedIdentifierNames: new Set(),
|
|
292
369
|
importSources: new Set(),
|
|
293
370
|
exportedVariableDeclarators: new Set(),
|
|
371
|
+
exportedLocalSymbols: new Set(),
|
|
294
372
|
};
|
|
295
373
|
const visitedSymbols = new Set<YukuSymbol>();
|
|
296
374
|
const scannedNodes = new Set<AnyNode>();
|
|
297
375
|
|
|
376
|
+
const visitIdentifier = (node: YukuReference['node']) => {
|
|
377
|
+
const reference = module.referenceOf(node);
|
|
378
|
+
if (reference) {
|
|
379
|
+
if (reference.symbol && isRuntimeRelevantReference(reference)) {
|
|
380
|
+
visitSymbol(reference.symbol);
|
|
381
|
+
}
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
const symbol = module.symbolOf(node);
|
|
385
|
+
if (
|
|
386
|
+
symbol?.scope === module.rootScope &&
|
|
387
|
+
symbol.has(SymbolFlags.ValueSpace | SymbolFlags.ValueImport) &&
|
|
388
|
+
dependencies.topLevelNonModuleStatements.has(
|
|
389
|
+
getCachedTopLevelStatementForNode(node)
|
|
390
|
+
)
|
|
391
|
+
) {
|
|
392
|
+
// Moving a statement also moves the bindings it declares. Follow
|
|
393
|
+
// their consumers so no references remain in another chunk.
|
|
394
|
+
visitSymbol(symbol);
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
|
|
298
398
|
const scanNode = (node: AnyNode) => {
|
|
299
399
|
if (scannedNodes.has(node)) {
|
|
300
400
|
return;
|
|
301
401
|
}
|
|
302
402
|
scannedNodes.add(node);
|
|
303
403
|
walk(node as any, {
|
|
304
|
-
Identifier
|
|
305
|
-
|
|
306
|
-
if (reference?.symbol) {
|
|
307
|
-
visitSymbol(reference.symbol);
|
|
308
|
-
}
|
|
309
|
-
},
|
|
404
|
+
Identifier: visitIdentifier,
|
|
405
|
+
JSXIdentifier: visitIdentifier,
|
|
310
406
|
});
|
|
311
407
|
};
|
|
312
408
|
|
|
@@ -318,6 +414,9 @@ const getExportDependencies = (
|
|
|
318
414
|
if (symbol.declarations.length === 0) {
|
|
319
415
|
return;
|
|
320
416
|
}
|
|
417
|
+
if (nonShareableExportedSymbols.has(symbol)) {
|
|
418
|
+
dependencies.exportedLocalSymbols.add(symbol);
|
|
419
|
+
}
|
|
321
420
|
|
|
322
421
|
for (const declaration of symbol.declarations as AnyNode[]) {
|
|
323
422
|
const statement = addCachedTopLevelStatement(
|
|
@@ -329,24 +428,27 @@ const getExportDependencies = (
|
|
|
329
428
|
if (typeof statement.source?.value === 'string') {
|
|
330
429
|
dependencies.importSources.add(statement.source.value);
|
|
331
430
|
}
|
|
332
|
-
|
|
431
|
+
// Ordinary imports are shareable; a directly exported import
|
|
432
|
+
// also owns setup statements such as `load.hydrate = true`.
|
|
433
|
+
if (symbol !== localSymbol) return;
|
|
333
434
|
}
|
|
334
|
-
const declarator =
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
isTopLevelExportedVariableDeclarator(module, declarator)
|
|
338
|
-
) {
|
|
435
|
+
const declarator =
|
|
436
|
+
getCachedExportedVariableDeclaratorForNode(declaration);
|
|
437
|
+
if (declarator) {
|
|
339
438
|
dependencies.exportedVariableDeclarators.add(declarator);
|
|
340
439
|
}
|
|
341
440
|
scanNode(declarator ?? statement);
|
|
342
441
|
}
|
|
343
442
|
|
|
344
|
-
for (const reference of symbol.references
|
|
443
|
+
for (const reference of symbol.references) {
|
|
444
|
+
if (!isRuntimeRelevantReference(reference)) {
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
345
447
|
const statement = addCachedTopLevelStatement(
|
|
346
448
|
dependencies,
|
|
347
449
|
reference.node
|
|
348
450
|
);
|
|
349
|
-
const declarator =
|
|
451
|
+
const declarator = getCachedExportedVariableDeclaratorForNode(
|
|
350
452
|
reference.node
|
|
351
453
|
);
|
|
352
454
|
scanNode(declarator ?? statement);
|
|
@@ -365,10 +467,7 @@ const getExportDependencies = (
|
|
|
365
467
|
exportDependencies.set(exportName, dependencies);
|
|
366
468
|
};
|
|
367
469
|
|
|
368
|
-
for (const exp of
|
|
369
|
-
if (exp.typeOnly || exp.isStar || exp.isExportEquals) {
|
|
370
|
-
continue;
|
|
371
|
-
}
|
|
470
|
+
for (const exp of namedExports) {
|
|
372
471
|
handleExport(exp.name, exp.node as AnyNode, exp.local ?? null);
|
|
373
472
|
}
|
|
374
473
|
|
|
@@ -383,7 +482,7 @@ const isExportChunkable = (
|
|
|
383
482
|
importer: string
|
|
384
483
|
) => {
|
|
385
484
|
const dependencies = exportDependencies.get(exportName);
|
|
386
|
-
if (!dependencies) {
|
|
485
|
+
if (!dependencies || dependencies.exportedVariableDeclarators.size > 1) {
|
|
387
486
|
return false;
|
|
388
487
|
}
|
|
389
488
|
if (exportName === 'clientLoader' && hasHydrateAssignment(dependencies)) {
|
|
@@ -403,29 +502,19 @@ const isExportChunkable = (
|
|
|
403
502
|
setsIntersect(
|
|
404
503
|
currentDependencies.topLevelNonModuleStatements,
|
|
405
504
|
dependencies.topLevelNonModuleStatements
|
|
505
|
+
) ||
|
|
506
|
+
setsIntersect(
|
|
507
|
+
currentDependencies.exportedVariableDeclarators,
|
|
508
|
+
dependencies.exportedVariableDeclarators
|
|
509
|
+
) ||
|
|
510
|
+
setsIntersect(
|
|
511
|
+
currentDependencies.exportedLocalSymbols,
|
|
512
|
+
dependencies.exportedLocalSymbols
|
|
406
513
|
)
|
|
407
514
|
) {
|
|
408
515
|
return false;
|
|
409
516
|
}
|
|
410
517
|
}
|
|
411
|
-
if (dependencies.exportedVariableDeclarators.size > 1) {
|
|
412
|
-
return false;
|
|
413
|
-
}
|
|
414
|
-
if (dependencies.exportedVariableDeclarators.size > 0) {
|
|
415
|
-
for (const [currentExportName, currentDependencies] of exportDependencies) {
|
|
416
|
-
if (currentExportName === exportName) {
|
|
417
|
-
continue;
|
|
418
|
-
}
|
|
419
|
-
if (
|
|
420
|
-
setsIntersect(
|
|
421
|
-
currentDependencies.exportedVariableDeclarators,
|
|
422
|
-
dependencies.exportedVariableDeclarators
|
|
423
|
-
)
|
|
424
|
-
) {
|
|
425
|
-
return false;
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
518
|
return true;
|
|
430
519
|
};
|
|
431
520
|
|
package/src/rsc-prerender.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
3
|
-
import { pathToFileURL } from 'node:url';
|
|
4
3
|
import type { RsbuildPluginAPI } from '@rsbuild/core';
|
|
5
4
|
import { dirname, relative, resolve } from 'pathe';
|
|
6
5
|
import * as Effect from 'effect/Effect';
|
|
7
6
|
import { PLUGIN_NAME, SPA_FALLBACK_HTML_FILE } from './constants.js';
|
|
7
|
+
import { startServerBuildWorker } from './server-build-worker-client.js';
|
|
8
8
|
import {
|
|
9
9
|
createBuildRequestEffect,
|
|
10
10
|
createBoundedPrerenderTasksEffect,
|
|
@@ -27,8 +27,9 @@ import { runPluginEffect } from './effect-runtime.js';
|
|
|
27
27
|
* inline `__FLIGHT_DATA` scripts, served for client-side navigations
|
|
28
28
|
*
|
|
29
29
|
* Instead of an HTTP round-trip through a preview server, the RSC server
|
|
30
|
-
* bundle's default-exported `fetch` handler is invoked in
|
|
31
|
-
* how classic mode prerenders through
|
|
30
|
+
* bundle's default-exported `fetch` handler is invoked directly (in the
|
|
31
|
+
* server build worker), matching how classic mode prerenders through
|
|
32
|
+
* `createRequestHandler`.
|
|
32
33
|
*/
|
|
33
34
|
|
|
34
35
|
export const SPA_FALLBACK_REQUEST_PATH: string = `/${SPA_FALLBACK_HTML_FILE}`;
|
|
@@ -145,29 +146,6 @@ const createRedirectHtml = ({
|
|
|
145
146
|
</html>`;
|
|
146
147
|
};
|
|
147
148
|
|
|
148
|
-
const resolveRscRequestHandler = (
|
|
149
|
-
buildModule: unknown,
|
|
150
|
-
serverBuildPath: string
|
|
151
|
-
): RscRequestHandler => {
|
|
152
|
-
const moduleRecord = buildModule as
|
|
153
|
-
| { default?: { fetch?: unknown; default?: { fetch?: unknown } } }
|
|
154
|
-
| undefined;
|
|
155
|
-
const handler =
|
|
156
|
-
typeof moduleRecord?.default?.fetch === 'function'
|
|
157
|
-
? moduleRecord.default.fetch
|
|
158
|
-
: typeof moduleRecord?.default?.default?.fetch === 'function'
|
|
159
|
-
? moduleRecord.default.default.fetch
|
|
160
|
-
: null;
|
|
161
|
-
if (!handler) {
|
|
162
|
-
throw new Error(
|
|
163
|
-
`[${PLUGIN_NAME}] RSC server build ${JSON.stringify(
|
|
164
|
-
serverBuildPath
|
|
165
|
-
)} must default-export an object with a fetch function.`
|
|
166
|
-
);
|
|
167
|
-
}
|
|
168
|
-
return handler as RscRequestHandler;
|
|
169
|
-
};
|
|
170
|
-
|
|
171
149
|
const writePrerenderedFile = async ({
|
|
172
150
|
api,
|
|
173
151
|
clientBuildDir,
|
|
@@ -330,11 +308,9 @@ export const runReactRouterRscPrerenderBuild = async (
|
|
|
330
308
|
const clientBuildDir = resolve(buildDirectory, 'client');
|
|
331
309
|
await mkdir(clientBuildDir, { recursive: true });
|
|
332
310
|
|
|
333
|
-
const
|
|
334
|
-
process.env.IS_RR_BUILD_REQUEST = 'yes';
|
|
311
|
+
const worker = await startServerBuildWorker({ serverBuildPath, mode: 'rsc' });
|
|
335
312
|
try {
|
|
336
|
-
const
|
|
337
|
-
const handler = resolveRscRequestHandler(buildModule, serverBuildPath);
|
|
313
|
+
const handler: RscRequestHandler = worker.handler;
|
|
338
314
|
|
|
339
315
|
api.logger.info(`Prerender: ${prerenderRequests.length} path(s)...`);
|
|
340
316
|
|
|
@@ -353,10 +329,6 @@ export const runReactRouterRscPrerenderBuild = async (
|
|
|
353
329
|
)
|
|
354
330
|
);
|
|
355
331
|
} finally {
|
|
356
|
-
|
|
357
|
-
delete process.env.IS_RR_BUILD_REQUEST;
|
|
358
|
-
} else {
|
|
359
|
-
process.env.IS_RR_BUILD_REQUEST = previousBuildRequestFlag;
|
|
360
|
-
}
|
|
332
|
+
await worker.close();
|
|
361
333
|
}
|
|
362
334
|
};
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
// Internal module: exposes ServerBuild resolution used by dev-runtime code.
|
|
2
2
|
// External callers go through the Promise wrappers in server-utils.ts.
|
|
3
3
|
import type { ServerBuild } from 'react-router';
|
|
4
|
-
import { normalizeEffectError } from './effect-runtime.js';
|
|
5
4
|
|
|
6
5
|
const RESOLVABLE_BUILD_EXPORTS = new Set([
|
|
7
6
|
'allowedActionOrigins',
|
|
@@ -116,6 +115,6 @@ export async function resolveServerBuildModule(
|
|
|
116
115
|
`[rsbuild-plugin-react-router] ${source} did not contain a valid React Router ServerBuild.`
|
|
117
116
|
);
|
|
118
117
|
} catch (cause) {
|
|
119
|
-
throw
|
|
118
|
+
throw cause instanceof Error ? cause : new Error(String(cause));
|
|
120
119
|
}
|
|
121
120
|
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url';
|
|
2
|
+
import { Worker } from 'node:worker_threads';
|
|
3
|
+
import { normalizeEffectError } from './effect-runtime.js';
|
|
4
|
+
import {
|
|
5
|
+
headerEntries,
|
|
6
|
+
type ServerBuildDescription,
|
|
7
|
+
type ServerBuildWorkerData,
|
|
8
|
+
type ServerBuildWorkerRequest,
|
|
9
|
+
type ServerBuildWorkerResponse,
|
|
10
|
+
} from './server-build-worker-protocol.js';
|
|
11
|
+
|
|
12
|
+
const defaultWorkerPath = fileURLToPath(
|
|
13
|
+
new URL('./server-build-worker.js', import.meta.url)
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
export type ServerBuildWorker = {
|
|
17
|
+
/** Plain-data view of the classic server build (routes, assets, prerender). */
|
|
18
|
+
description: ServerBuildDescription | undefined;
|
|
19
|
+
/** Runs the request against the server build in the worker. */
|
|
20
|
+
handler(request: Request): Promise<Response>;
|
|
21
|
+
/** Terminates the worker, and with it any handle the server graph opened. */
|
|
22
|
+
close(): Promise<void>;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type Reply = Exclude<ServerBuildWorkerResponse, { type: 'ready' | 'closed' }>;
|
|
26
|
+
|
|
27
|
+
type Pending = {
|
|
28
|
+
resolve: (reply: Reply) => void;
|
|
29
|
+
reject: (error: Error) => void;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const replyError = (reply: Extract<Reply, { ok: false }>): Error => {
|
|
33
|
+
const error = new Error(reply.error.message);
|
|
34
|
+
error.name = reply.error.name ?? error.name;
|
|
35
|
+
if (reply.error.stack) {
|
|
36
|
+
error.stack = reply.error.stack;
|
|
37
|
+
}
|
|
38
|
+
return error;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Evaluate a built server bundle in a worker thread and proxy requests to it.
|
|
43
|
+
* Build-time rendering used to `import()` the bundle into the build process;
|
|
44
|
+
* a module-scope handle in the app's server graph then kept `rsbuild build`
|
|
45
|
+
* alive forever (#135). The worker is terminated by `close()`.
|
|
46
|
+
*
|
|
47
|
+
* The worker's `exit` is its final event, so any exit (including one between
|
|
48
|
+
* requests, e.g. the app calling `process.exit`) is terminal: outstanding and
|
|
49
|
+
* later requests reject instead of waiting for a reply that cannot come.
|
|
50
|
+
*/
|
|
51
|
+
export const startServerBuildWorker = async (
|
|
52
|
+
data: ServerBuildWorkerData,
|
|
53
|
+
// Tests run from `src/` and point this at the built worker.
|
|
54
|
+
workerPath: string = defaultWorkerPath
|
|
55
|
+
): Promise<ServerBuildWorker> => {
|
|
56
|
+
const worker = new Worker(workerPath, { workerData: data });
|
|
57
|
+
const pending = new Map<number, Pending>();
|
|
58
|
+
const active = new Map<number, (error: Error) => void>();
|
|
59
|
+
let nextId = 0;
|
|
60
|
+
let failure: Error | undefined;
|
|
61
|
+
let acknowledgeClose: () => void = () => {};
|
|
62
|
+
const closed = new Promise<void>(resolve => {
|
|
63
|
+
acknowledgeClose = resolve;
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const fail = (error: Error): void => {
|
|
67
|
+
failure ??= error;
|
|
68
|
+
for (const { reject } of pending.values()) {
|
|
69
|
+
reject(failure);
|
|
70
|
+
}
|
|
71
|
+
pending.clear();
|
|
72
|
+
for (const stop of active.values()) stop(failure);
|
|
73
|
+
active.clear();
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const ready = new Promise<ServerBuildDescription | undefined>(
|
|
77
|
+
(resolve, reject) => {
|
|
78
|
+
worker.on('message', (message: ServerBuildWorkerResponse) => {
|
|
79
|
+
if (message.type === 'closed') {
|
|
80
|
+
acknowledgeClose();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (message.type === 'ready') {
|
|
84
|
+
resolve(message.description);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const entry = pending.get(message.id);
|
|
88
|
+
pending.delete(message.id);
|
|
89
|
+
entry?.resolve(message);
|
|
90
|
+
});
|
|
91
|
+
worker.on('error', error => {
|
|
92
|
+
acknowledgeClose();
|
|
93
|
+
fail(normalizeEffectError(error));
|
|
94
|
+
reject(failure);
|
|
95
|
+
});
|
|
96
|
+
worker.on('exit', code => {
|
|
97
|
+
acknowledgeClose();
|
|
98
|
+
fail(new Error(`Server build worker exited with code ${code}`));
|
|
99
|
+
reject(failure);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const send = (
|
|
105
|
+
request: ServerBuildWorkerRequest,
|
|
106
|
+
transfer: ArrayBuffer[] = []
|
|
107
|
+
): void => {
|
|
108
|
+
worker.postMessage(request, transfer);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// Import errors surface as worker 'error' events, an early exit as 'exit'.
|
|
112
|
+
const description = await ready;
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
description,
|
|
116
|
+
async handler(request) {
|
|
117
|
+
const id = nextId++;
|
|
118
|
+
const body = request.body
|
|
119
|
+
? new Uint8Array(await request.arrayBuffer())
|
|
120
|
+
: undefined;
|
|
121
|
+
let responseController:
|
|
122
|
+
| ReadableStreamDefaultController<Uint8Array>
|
|
123
|
+
| undefined;
|
|
124
|
+
const cleanup = (): void => {
|
|
125
|
+
active.delete(id);
|
|
126
|
+
request.signal.removeEventListener('abort', onAbort);
|
|
127
|
+
};
|
|
128
|
+
const stop = (error: Error): void => {
|
|
129
|
+
responseController?.error(error);
|
|
130
|
+
pending.get(id)?.reject(error);
|
|
131
|
+
pending.delete(id);
|
|
132
|
+
cleanup();
|
|
133
|
+
};
|
|
134
|
+
// Keep the relay alive after headers arrive: RSC may release a redirect
|
|
135
|
+
// or rejected status without ever consuming its response body.
|
|
136
|
+
const onAbort = (): void => {
|
|
137
|
+
send({ type: 'abort', id });
|
|
138
|
+
if (responseController)
|
|
139
|
+
stop(new Error('Server build request was aborted'));
|
|
140
|
+
};
|
|
141
|
+
const reply = await new Promise<Reply>((resolve, reject) => {
|
|
142
|
+
if (failure) {
|
|
143
|
+
reject(failure);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
pending.set(id, { resolve, reject });
|
|
147
|
+
active.set(id, stop);
|
|
148
|
+
request.signal.addEventListener('abort', onAbort, { once: true });
|
|
149
|
+
send(
|
|
150
|
+
{
|
|
151
|
+
type: 'request',
|
|
152
|
+
id,
|
|
153
|
+
url: request.url,
|
|
154
|
+
method: request.method,
|
|
155
|
+
headers: headerEntries(request.headers),
|
|
156
|
+
body,
|
|
157
|
+
},
|
|
158
|
+
body ? [body.buffer] : []
|
|
159
|
+
);
|
|
160
|
+
if (request.signal.aborted) onAbort();
|
|
161
|
+
}).catch(error => {
|
|
162
|
+
cleanup();
|
|
163
|
+
throw error;
|
|
164
|
+
});
|
|
165
|
+
if (!reply.ok) {
|
|
166
|
+
cleanup();
|
|
167
|
+
throw replyError(reply);
|
|
168
|
+
}
|
|
169
|
+
if (reply.type !== 'reply')
|
|
170
|
+
throw new Error('Unexpected server build response');
|
|
171
|
+
const { status, statusText, headers, hasBody } = reply.response;
|
|
172
|
+
const responseBody = hasBody
|
|
173
|
+
? new ReadableStream<Uint8Array>(
|
|
174
|
+
{
|
|
175
|
+
start(controller) {
|
|
176
|
+
responseController = controller;
|
|
177
|
+
},
|
|
178
|
+
async pull(controller) {
|
|
179
|
+
try {
|
|
180
|
+
const result = await new Promise<Reply>((resolve, reject) => {
|
|
181
|
+
if (failure) {
|
|
182
|
+
reject(failure);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
pending.set(id, { resolve, reject });
|
|
186
|
+
send({ type: 'read', id });
|
|
187
|
+
});
|
|
188
|
+
if (!result.ok) throw replyError(result);
|
|
189
|
+
if (result.type !== 'body')
|
|
190
|
+
throw new Error('Unexpected server build body');
|
|
191
|
+
controller.enqueue(result.body);
|
|
192
|
+
controller.close();
|
|
193
|
+
} catch (error) {
|
|
194
|
+
controller.error(error);
|
|
195
|
+
} finally {
|
|
196
|
+
cleanup();
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
cancel() {
|
|
200
|
+
send({ type: 'abort', id });
|
|
201
|
+
stop(new Error('Server build response was canceled'));
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
{ highWaterMark: 0 }
|
|
205
|
+
)
|
|
206
|
+
: null;
|
|
207
|
+
if (!hasBody) cleanup();
|
|
208
|
+
return new Response(responseBody, {
|
|
209
|
+
status,
|
|
210
|
+
statusText,
|
|
211
|
+
headers,
|
|
212
|
+
});
|
|
213
|
+
},
|
|
214
|
+
async close() {
|
|
215
|
+
fail(new Error('Server build worker was closed'));
|
|
216
|
+
send({ type: 'close' });
|
|
217
|
+
await closed;
|
|
218
|
+
await worker.terminate();
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
};
|