@octanejs/rsbuild-plugin 0.1.48 → 0.1.50

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.48",
3
+ "version": "0.1.50",
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.49",
52
- "@octanejs/rspack-plugin": "0.1.48"
51
+ "@octanejs/app-core": "0.0.51",
52
+ "@octanejs/rspack-plugin": "0.1.50"
53
53
  },
54
54
  "peerDependencies": {
55
55
  "@rsbuild/core": "^2.0.0",
56
- "octane": "^0.1.51 || ^0.2.0"
56
+ "octane": "^0.2.13"
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.4"
65
+ "octane": "0.2.13"
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,
@@ -135,9 +138,8 @@ function hasRoutes(config) {
135
138
  /**
136
139
  * @param {import('@octanejs/app-core').ResolvedOctaneConfig} config
137
140
  * @param {boolean | undefined} strongOverride
138
- * @param {boolean | undefined} nativeReadsOverride
139
141
  */
140
- function configSignature(config, strongOverride, nativeReadsOverride) {
142
+ function configSignature(config, strongOverride) {
141
143
  return JSON.stringify({
142
144
  build: config.build,
143
145
  routes: config.router.routes.map((route) =>
@@ -157,7 +159,6 @@ function configSignature(config, strongOverride, nativeReadsOverride) {
157
159
  compiler: {
158
160
  renderers: config.compiler.renderers.signature,
159
161
  strong: strongOverride ?? config.compiler.strong,
160
- nativeReads: nativeReadsOverride ?? config.compiler.nativeReads,
161
162
  },
162
163
  });
163
164
  }
@@ -237,9 +238,9 @@ function assertRootPublicPaths(config, clientEnvironment) {
237
238
  * hmr?: boolean,
238
239
  * parallel?: boolean | { maxWorkers?: number },
239
240
  * cssModuleConstants?: import('@octanejs/rspack-plugin').OctaneRspackPluginOptions['cssModuleConstants'],
241
+ * textTypes?: import('@octanejs/rspack-plugin').OctaneRspackPluginOptions['textTypes'],
240
242
  * profile?: boolean,
241
243
  * strong?: boolean,
242
- * nativeReads?: boolean,
243
244
  * exclude?: string[],
244
245
  * requireDirective?: boolean,
245
246
  * clientEnvironment?: string,
@@ -248,9 +249,6 @@ function assertRootPublicPaths(config, clientEnvironment) {
248
249
  * @returns {import('@rsbuild/core').RsbuildPlugin}
249
250
  */
250
251
  export function pluginOctane(inlineOptions = {}) {
251
- if (inlineOptions.nativeReads !== undefined && typeof inlineOptions.nativeReads !== 'boolean') {
252
- throw new TypeError('[@octanejs/rsbuild-plugin] nativeReads must be a boolean.');
253
- }
254
252
  if (inlineOptions.strong !== undefined && typeof inlineOptions.strong !== 'boolean') {
255
253
  throw new TypeError('[@octanejs/rsbuild-plugin] `strong` must be a boolean.');
256
254
  }
@@ -272,7 +270,6 @@ export function pluginOctane(inlineOptions = {}) {
272
270
  : null;
273
271
  const initialConfig = initialLoaded?.config ?? null;
274
272
  const strong = inlineOptions.strong ?? initialConfig?.compiler.strong;
275
- const nativeReads = inlineOptions.nativeReads ?? initialConfig?.compiler.nativeReads;
276
273
  const appEnabled = hasRoutes(initialConfig);
277
274
  const buildTargetPlan =
278
275
  initialConfig?.build.target === undefined
@@ -282,12 +279,11 @@ export function pluginOctane(inlineOptions = {}) {
282
279
  ? createOctaneCompiler({
283
280
  root,
284
281
  ...(strong === undefined ? null : { strong }),
285
- ...(nativeReads === undefined ? null : { nativeReads }),
286
282
  })
287
283
  : null;
288
284
  let currentConfig = initialConfig;
289
285
  let lastConfigSignature = initialLoaded
290
- ? configSignature(initialLoaded.config, inlineOptions.strong, inlineOptions.nativeReads)
286
+ ? configSignature(initialLoaded.config, inlineOptions.strong)
291
287
  : '';
292
288
  let configNeedsReload = false;
293
289
  /** @type {import('@rsbuild/core').RsbuildDevServer | null} */
@@ -335,11 +331,7 @@ export function pluginOctane(inlineOptions = {}) {
335
331
  throw error;
336
332
  }
337
333
  registerConfigDependencies(context, loaded);
338
- const signature = configSignature(
339
- loaded.config,
340
- inlineOptions.strong,
341
- inlineOptions.nativeReads,
342
- );
334
+ const signature = configSignature(loaded.config, inlineOptions.strong);
343
335
  if (lastConfigSignature && signature !== lastConfigSignature) configNeedsReload = true;
344
336
  lastConfigSignature = signature;
345
337
  currentConfig = loaded.config;
@@ -408,6 +400,18 @@ export function pluginOctane(inlineOptions = {}) {
408
400
  server: { htmlFallback: false, historyApiFallback: false },
409
401
  environments: {
410
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
+ : {}),
411
415
  ...targetConfig,
412
416
  source: { entry: { [CLIENT_ENTRY_NAME]: clientEntryFile } },
413
417
  html: { template },
@@ -491,6 +495,7 @@ export function pluginOctane(inlineOptions = {}) {
491
495
  }));
492
496
  return create_client_entry_source({
493
497
  staticEntries: entries,
498
+ clientBuildIdExpression: '__webpack_hash__',
494
499
  generatedBy: PLUGIN_NAME,
495
500
  });
496
501
  },
@@ -531,7 +536,11 @@ export function pluginOctane(inlineOptions = {}) {
531
536
  rpcModulePaths: serverModules.ids,
532
537
  ...(webWorkerServer ? { mode: 'webworker' } : null),
533
538
  ...(production && !webWorkerServer
534
- ? { clientAssetMapFile: CLIENT_ASSET_MAP }
539
+ ? {
540
+ clientAssetMapFile: CLIENT_ASSET_MAP,
541
+ clientBuildFile: 'octane-client-build.json',
542
+ independentHydrationManifestFile: INDEPENDENT_HYDRATION_MANIFEST_FILENAME,
543
+ }
535
544
  : { clientAssetMap: {} }),
536
545
  resolveImport: (id) => resolveProjectModule(id, root),
537
546
  configModuleId: localRequire.resolve('@octanejs/app-core/config'),
@@ -562,13 +571,16 @@ export function pluginOctane(inlineOptions = {}) {
562
571
  new OctaneRspackPlugin({
563
572
  root,
564
573
  environment,
574
+ clientBuildMode: productionBuild ? 'production' : 'development',
565
575
  transpile: false,
566
576
  ...(inlineOptions.parallel === undefined ? null : { parallel: inlineOptions.parallel }),
567
577
  ...(inlineOptions.cssModuleConstants === undefined
568
578
  ? null
569
579
  : { cssModuleConstants: inlineOptions.cssModuleConstants }),
580
+ ...(inlineOptions.textTypes === undefined
581
+ ? null
582
+ : { textTypes: inlineOptions.textTypes }),
570
583
  ...(strong === undefined ? null : { strong }),
571
- ...(nativeReads === undefined ? null : { nativeReads }),
572
584
  ...(inlineOptions.hmr === undefined ? null : { hmr: inlineOptions.hmr }),
573
585
  ...(inlineOptions.profile === undefined
574
586
  ? null
@@ -654,6 +666,9 @@ export function pluginOctane(inlineOptions = {}) {
654
666
 
655
667
  api.modifyHTML((html, context) => {
656
668
  if (context.environment.name !== clientEnvironment) return html;
669
+ if (isProductionBuild()) {
670
+ assertEarlyHydrationEntry(context.compilation, CLIENT_ENTRY_NAME, clientEntryFile);
671
+ }
657
672
  const entrypoint = context.compilation.entrypoints.get(CLIENT_ENTRY_NAME);
658
673
  const entryFiles = entrypoint?.getEntrypointChunk().files ?? [];
659
674
  return markHydrationEntry(html, entryFiles);
@@ -694,6 +709,7 @@ export function pluginOctane(inlineOptions = {}) {
694
709
  root,
695
710
  config: loaded.config,
696
711
  assetMapFilename: CLIENT_ASSET_MAP,
712
+ independentHydrationManifestFilename: INDEPENDENT_HYDRATION_MANIFEST_FILENAME,
697
713
  log: createLog(api.logger),
698
714
  });
699
715
  });
package/types/index.d.ts CHANGED
@@ -10,6 +10,13 @@ export {
10
10
  } from '@octanejs/app-core/config-loader';
11
11
 
12
12
  export interface OctaneRsbuildPluginOptions {
13
+ /**
14
+ * @experimental TypeScript text proofs in one-shot production browser and
15
+ * server builds. Relative tsconfig paths resolve from the project root.
16
+ * Watch/HMR and development builds retain syntax-only classification.
17
+ * Requires the optional TypeScript peer; see the Rspack option for caching.
18
+ */
19
+ textTypes?: OctaneRspackPluginOptions['textTypes'];
13
20
  /**
14
21
  * @experimental Fold authenticated JavaScript CSS-module exports in one-shot
15
22
  * production builds. Uses the Rspack plugin's exact-source provider contract;
@@ -33,8 +40,6 @@ export interface OctaneRsbuildPluginOptions {
33
40
  * in with `"use strong"`.
34
41
  */
35
42
  strong?: boolean;
36
- /** Experimental native signal reads in DOM client/server render scopes. */
37
- nativeReads?: boolean;
38
43
  /**
39
44
  * Ad-hoc path fragments skipped by the plain TypeScript/JavaScript
40
45
  * hook-slot pass. With `requireDirective`, excluded paths are exempt from