@octanejs/rsbuild-plugin 0.1.49 → 0.1.51

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@octanejs/rsbuild-plugin",
3
- "version": "0.1.49",
3
+ "version": "0.1.51",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "engines": {
@@ -48,12 +48,12 @@
48
48
  }
49
49
  },
50
50
  "dependencies": {
51
- "@octanejs/app-core": "0.0.50",
52
- "@octanejs/rspack-plugin": "0.1.49"
51
+ "@octanejs/app-core": "0.0.52",
52
+ "@octanejs/rspack-plugin": "0.1.51"
53
53
  },
54
54
  "peerDependencies": {
55
55
  "@rsbuild/core": "^2.0.0",
56
- "octane": "^0.1.51 || ^0.2.0"
56
+ "octane": "^0.3.0"
57
57
  },
58
58
  "devDependencies": {
59
59
  "@rsbuild/core": "^2.1.6",
@@ -62,6 +62,6 @@
62
62
  "@types/node": "^24.13.3",
63
63
  "playwright": "^1.61.1",
64
64
  "vitest": "^4.1.10",
65
- "octane": "0.2.7"
65
+ "octane": "0.3.0"
66
66
  }
67
67
  }
package/src/build.js CHANGED
@@ -10,6 +10,8 @@ import path from 'node:path';
10
10
  * root: string,
11
11
  * config: import('@octanejs/app-core').ResolvedOctaneConfig,
12
12
  * assetMapFilename?: string,
13
+ * clientBuildFilename?: string,
14
+ * independentHydrationManifestFilename?: string,
13
15
  * log?: (message: string) => void,
14
16
  * }} options
15
17
  */
@@ -19,11 +21,18 @@ export async function finalizeOctaneRsbuildOutput(options) {
19
21
  const clientDir = path.resolve(root, outDir, 'client');
20
22
  const serverDir = path.resolve(root, outDir, 'server');
21
23
  const assetMapFilename = options.assetMapFilename ?? 'octane-client-assets.json';
24
+ const clientBuildFilename = options.clientBuildFilename ?? 'octane-client-build.json';
25
+ const independentHydrationManifestFilename =
26
+ options.independentHydrationManifestFilename ?? 'octane-independent-hydration.json';
22
27
  const log = options.log ?? (() => {});
23
28
  const clientHtml = path.join(clientDir, 'index.html');
24
29
  const serverHtml = path.join(serverDir, 'index.html');
25
30
  const clientAssetMap = path.join(clientDir, assetMapFilename);
26
31
  const serverAssetMap = path.join(serverDir, assetMapFilename);
32
+ const clientBuildPath = path.join(clientDir, clientBuildFilename);
33
+ const serverBuildPath = path.join(serverDir, clientBuildFilename);
34
+ const clientIndependentHydration = path.join(clientDir, independentHydrationManifestFilename);
35
+ const serverIndependentHydration = path.join(serverDir, independentHydrationManifestFilename);
27
36
  const serverEntry = path.join(serverDir, 'entry.js');
28
37
 
29
38
  if (!fs.existsSync(clientHtml)) {
@@ -37,12 +46,45 @@ export async function finalizeOctaneRsbuildOutput(options) {
37
46
  if (!fs.existsSync(serverEntry)) {
38
47
  throw new Error(`[@octanejs/rsbuild-plugin] Server entry was not emitted at ${serverEntry}.`);
39
48
  }
49
+ if (!fs.existsSync(clientBuildPath)) {
50
+ throw new Error(
51
+ `[@octanejs/rsbuild-plugin] Client build metadata was not emitted at ${clientBuildPath}.`,
52
+ );
53
+ }
54
+ const clientBuild = JSON.parse(fs.readFileSync(clientBuildPath, 'utf8'));
55
+ if (
56
+ clientBuild?.version !== 1 ||
57
+ typeof clientBuild.buildId !== 'string' ||
58
+ !clientBuild.buildId ||
59
+ clientBuild.mode !== 'production' ||
60
+ typeof clientBuild.capabilities?.independentHydration !== 'boolean'
61
+ ) {
62
+ throw new Error('[@octanejs/rsbuild-plugin] Invalid production client build metadata.');
63
+ }
64
+ if (fs.existsSync(clientIndependentHydration)) {
65
+ const widgets = JSON.parse(fs.readFileSync(clientIndependentHydration, 'utf8'));
66
+ if (widgets.buildId !== clientBuild.buildId) {
67
+ throw new Error(
68
+ '[@octanejs/rsbuild-plugin] Independent hydration and client build metadata disagree.',
69
+ );
70
+ }
71
+ } else if (clientBuild.capabilities.independentHydration) {
72
+ throw new Error(
73
+ '[@octanejs/rsbuild-plugin] Client build is missing independent hydration metadata.',
74
+ );
75
+ }
40
76
 
41
77
  fs.mkdirSync(serverDir, { recursive: true });
42
78
  fs.rmSync(serverHtml, { force: true });
43
79
  fs.rmSync(serverAssetMap, { force: true });
80
+ fs.rmSync(serverBuildPath, { force: true });
44
81
  fs.renameSync(clientHtml, serverHtml);
45
82
  fs.renameSync(clientAssetMap, serverAssetMap);
83
+ fs.renameSync(clientBuildPath, serverBuildPath);
84
+ fs.rmSync(serverIndependentHydration, { force: true });
85
+ if (fs.existsSync(clientIndependentHydration)) {
86
+ fs.renameSync(clientIndependentHydration, serverIndependentHydration);
87
+ }
46
88
 
47
89
  log(`Server build complete: ${path.relative(root, serverEntry)}`);
48
90
  if (options.config.adapter?.serverTarget !== 'webworker') {
package/src/dev-server.js CHANGED
@@ -5,6 +5,8 @@ import { nodeRequestToWebRequest, sendWebResponse } from '@octanejs/app-core/nod
5
5
 
6
6
  import { isRsbuildOwnedUrl } from './html.js';
7
7
 
8
+ const INDEPENDENT_HYDRATION_MANIFEST_FILENAME = 'octane-independent-hydration.json';
9
+
8
10
  /**
9
11
  * @typedef {{
10
12
  * manifest: import('@octanejs/app-core/production').ServerManifest,
@@ -27,6 +29,35 @@ function collectAssetPaths(stats) {
27
29
  return paths;
28
30
  }
29
31
 
32
+ /** @param {import('@rspack/core').Stats} stats */
33
+ function readIndependentHydrationManifest(stats) {
34
+ const asset = stats.compilation.getAsset?.(INDEPENDENT_HYDRATION_MANIFEST_FILENAME);
35
+ if (!asset) return null;
36
+ const source = asset.source.source();
37
+ return JSON.parse(typeof source === 'string' ? source : Buffer.from(source).toString('utf8'));
38
+ }
39
+
40
+ /** @param {import('@rspack/core').Stats} stats */
41
+ function readClientBuild(stats) {
42
+ const asset = stats.compilation.getAsset?.('octane-client-build.json');
43
+ if (!asset) throw new Error('The Octane client build metadata is missing.');
44
+ const source = asset.source.source();
45
+ const metadata = JSON.parse(
46
+ typeof source === 'string' ? source : Buffer.from(source).toString('utf8'),
47
+ );
48
+ if (
49
+ metadata?.version !== 1 ||
50
+ metadata.buildId !== stats.hash ||
51
+ metadata.mode !== 'development' ||
52
+ typeof metadata.capabilities?.independentHydration !== 'boolean'
53
+ ) {
54
+ throw new Error(
55
+ 'The Octane client build metadata is incompatible with this development compilation.',
56
+ );
57
+ }
58
+ return metadata;
59
+ }
60
+
30
61
  /**
31
62
  * Create the early Connect middleware used by the Rsbuild Environment API.
32
63
  * Asset/internal requests fall through to Rsbuild; matched app/RPC requests
@@ -54,7 +85,7 @@ export function createOctaneDevMiddleware(options) {
54
85
 
55
86
  let assetHash = '';
56
87
  let assetPaths = new Set();
57
- /** @type {WeakMap<object, { html: string, handler: (request: Request) => Promise<Response> }>} */
88
+ /** @type {WeakMap<object, { html: string, assetHash: string, handler: (request: Request) => Promise<Response> }>} */
58
89
  const handlerCache = new WeakMap();
59
90
 
60
91
  return async function octaneDevMiddleware(request, response, next) {
@@ -69,6 +100,7 @@ export function createOctaneDevMiddleware(options) {
69
100
  return;
70
101
  }
71
102
  const clientStats = await clientApi.getStats();
103
+ if (clientStats.hasErrors()) throw new Error('The Octane client compilation failed.');
72
104
  if (clientStats.hash !== assetHash) {
73
105
  assetHash = clientStats.hash ?? '';
74
106
  assetPaths = collectAssetPaths(clientStats);
@@ -92,13 +124,24 @@ export function createOctaneDevMiddleware(options) {
92
124
 
93
125
  const html = await clientApi.getTransformedHtml(options.clientEntry);
94
126
  let cached = handlerCache.get(typedBundle.manifest);
95
- if (!cached || cached.html !== html) {
127
+ if (!cached || cached.html !== html || cached.assetHash !== assetHash) {
128
+ const independentHydration = readIndependentHydrationManifest(clientStats);
129
+ const clientBuild = readClientBuild(clientStats);
130
+ if (independentHydration !== null && independentHydration.buildId !== clientBuild.buildId) {
131
+ throw new Error(
132
+ 'The Octane independent widget metadata belongs to another client build.',
133
+ );
134
+ }
96
135
  cached = {
97
136
  html,
98
- handler: createHandler(typedBundle.manifest, {
99
- ...typedBundle.rendererDeps,
100
- htmlTemplate: html,
101
- }),
137
+ assetHash,
138
+ handler: createHandler(
139
+ { ...typedBundle.manifest, independentHydration, clientBuild },
140
+ {
141
+ ...typedBundle.rendererDeps,
142
+ htmlTemplate: html,
143
+ },
144
+ ),
102
145
  };
103
146
  handlerCache.set(typedBundle.manifest, cached);
104
147
  }
package/src/html.js CHANGED
@@ -9,6 +9,42 @@ function escapeRegExp(value) {
9
9
  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
10
10
  }
11
11
 
12
+ /**
13
+ * A classic bootstrap can run after the streamed shell only if its native
14
+ * entrypoint needs no deferred prerequisite and starts no other entry module.
15
+ * Keep the bundler's runtime/chunk ownership intact; reject unsafe overrides.
16
+ *
17
+ * @param {import('@rspack/core').Compilation} compilation
18
+ * @param {string} name
19
+ * @param {string} clientEntry
20
+ */
21
+ export function assertEarlyHydrationEntry(compilation, name, clientEntry) {
22
+ const entrypoint = compilation.entrypoints.get(name);
23
+ const chunk = entrypoint?.getEntrypointChunk();
24
+ const files = entrypoint?.getFiles().filter((file) => /\.m?js(?:\?.*)?$/.test(file)) ?? [];
25
+ if (!chunk || files.length !== 1 || !chunk.files.has(files[0])) {
26
+ throw new Error(
27
+ 'Octane early hydration requires one self-contained entry script. ' +
28
+ 'Do not extract its runtime or initial JavaScript dependencies into deferred chunks.',
29
+ );
30
+ }
31
+ const entries = [...compilation.chunkGraph.getChunkEntryModulesIterable(chunk)];
32
+ const entry = entries[0];
33
+ const root = entry && 'rootModule' in entry ? (entry.rootModule ?? entry) : entry;
34
+ const resource =
35
+ root && typeof root === 'object' && 'resource' in root ? root.resource : undefined;
36
+ if (
37
+ entries.length !== 1 ||
38
+ typeof resource !== 'string' ||
39
+ fs.realpathSync(resource.split('?', 1)[0]) !== fs.realpathSync(clientEntry)
40
+ ) {
41
+ throw new Error(
42
+ 'Octane early hydration must own its entry module exclusively. ' +
43
+ 'Keep user entries and pre-entry scripts separate from the generated hydration entry.',
44
+ );
45
+ }
46
+ }
47
+
12
48
  /**
13
49
  * Rsbuild has already injected the web entry by the time `modifyHTML` runs.
14
50
  * Mark the concrete generated tag so the shared production runtime can add a
package/src/index.js CHANGED
@@ -25,12 +25,15 @@ import {
25
25
  write_project_generated_file,
26
26
  } from '@octanejs/app-core/codegen';
27
27
  import { OctaneRspackPlugin } from '@octanejs/rspack-plugin';
28
- import { createOctaneCompiler } from 'octane/compiler/bundler';
28
+ import {
29
+ INDEPENDENT_HYDRATION_MANIFEST_FILENAME,
30
+ createOctaneCompiler,
31
+ } from 'octane/compiler/bundler';
29
32
 
30
33
  import { finalizeOctaneRsbuildOutput } from './build.js';
31
34
  import { OctaneClientAssetsPlugin } from './client-assets-plugin.js';
32
35
  import { createOctaneDevMiddleware } from './dev-server.js';
33
- import { markHydrationEntry } from './html.js';
36
+ import { assertEarlyHydrationEntry, markHydrationEntry } from './html.js';
34
37
  import {
35
38
  collectClientEntries,
36
39
  discoverServerModules,
@@ -397,6 +400,18 @@ export function pluginOctane(inlineOptions = {}) {
397
400
  server: { htmlFallback: false, historyApiFallback: false },
398
401
  environments: {
399
402
  [clientEnvironment]: {
403
+ // Keep the generated bootstrap self-contained by default, including
404
+ // downlevel helpers. Explicit splitting remains subject to the
405
+ // emitted-entry ownership check instead of being silently replaced.
406
+ ...(watchedConfig.splitChunks !== false &&
407
+ Object.keys(watchedConfig.splitChunks ?? {}).length === 0 &&
408
+ watchedConfig.environments?.[clientEnvironment]?.splitChunks !== false &&
409
+ Object.keys(watchedConfig.environments?.[clientEnvironment]?.splitChunks ?? {})
410
+ .length === 0 &&
411
+ watchedConfig.performance?.chunkSplit === undefined &&
412
+ watchedConfig.environments?.[clientEnvironment]?.performance?.chunkSplit === undefined
413
+ ? { splitChunks: { chunks: 'async' } }
414
+ : {}),
400
415
  ...targetConfig,
401
416
  source: { entry: { [CLIENT_ENTRY_NAME]: clientEntryFile } },
402
417
  html: { template },
@@ -480,6 +495,7 @@ export function pluginOctane(inlineOptions = {}) {
480
495
  }));
481
496
  return create_client_entry_source({
482
497
  staticEntries: entries,
498
+ clientBuildIdExpression: '__webpack_hash__',
483
499
  generatedBy: PLUGIN_NAME,
484
500
  });
485
501
  },
@@ -520,7 +536,11 @@ export function pluginOctane(inlineOptions = {}) {
520
536
  rpcModulePaths: serverModules.ids,
521
537
  ...(webWorkerServer ? { mode: 'webworker' } : null),
522
538
  ...(production && !webWorkerServer
523
- ? { clientAssetMapFile: CLIENT_ASSET_MAP }
539
+ ? {
540
+ clientAssetMapFile: CLIENT_ASSET_MAP,
541
+ clientBuildFile: 'octane-client-build.json',
542
+ independentHydrationManifestFile: INDEPENDENT_HYDRATION_MANIFEST_FILENAME,
543
+ }
524
544
  : { clientAssetMap: {} }),
525
545
  resolveImport: (id) => resolveProjectModule(id, root),
526
546
  configModuleId: localRequire.resolve('@octanejs/app-core/config'),
@@ -551,6 +571,7 @@ export function pluginOctane(inlineOptions = {}) {
551
571
  new OctaneRspackPlugin({
552
572
  root,
553
573
  environment,
574
+ clientBuildMode: productionBuild ? 'production' : 'development',
554
575
  transpile: false,
555
576
  ...(inlineOptions.parallel === undefined ? null : { parallel: inlineOptions.parallel }),
556
577
  ...(inlineOptions.cssModuleConstants === undefined
@@ -645,6 +666,9 @@ export function pluginOctane(inlineOptions = {}) {
645
666
 
646
667
  api.modifyHTML((html, context) => {
647
668
  if (context.environment.name !== clientEnvironment) return html;
669
+ if (isProductionBuild()) {
670
+ assertEarlyHydrationEntry(context.compilation, CLIENT_ENTRY_NAME, clientEntryFile);
671
+ }
648
672
  const entrypoint = context.compilation.entrypoints.get(CLIENT_ENTRY_NAME);
649
673
  const entryFiles = entrypoint?.getEntrypointChunk().files ?? [];
650
674
  return markHydrationEntry(html, entryFiles);
@@ -685,6 +709,7 @@ export function pluginOctane(inlineOptions = {}) {
685
709
  root,
686
710
  config: loaded.config,
687
711
  assetMapFilename: CLIENT_ASSET_MAP,
712
+ independentHydrationManifestFilename: INDEPENDENT_HYDRATION_MANIFEST_FILENAME,
688
713
  log: createLog(api.logger),
689
714
  });
690
715
  });