@octanejs/rspack-plugin 0.1.0 → 0.1.4

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 CHANGED
@@ -44,8 +44,28 @@ new OctaneRspackPlugin({ environment: 'server' });
44
44
 
45
45
  Set `transpile: false` when an existing rule already strips TypeScript. Set
46
46
  `hmr: false` to disable Octane HMR codegen even when Rspack HMR is active.
47
- Options contain only serializable strings, booleans, and string arrays, so the
48
- same configuration is safe to reuse across compiler environments and caches.
47
+ Set `profile: true` to produce a client profiling build; server compilations
48
+ always keep profiling disabled.
49
+ The experimental `renderers` option accepts the same declarative registry,
50
+ filename rules, and module/export boundary metadata as `compiler.renderers` in
51
+ Octane app config:
52
+
53
+ ```js
54
+ new OctaneRspackPlugin({
55
+ renderers: {
56
+ registry: { three: '@octanejs/three/renderer' },
57
+ boundaries: {
58
+ '@octanejs/three': {
59
+ Canvas: { ownerRenderer: 'dom', childRenderer: 'three', prop: 'children' },
60
+ },
61
+ },
62
+ rules: [{ include: 'src/scenes/**/*.tsrx', renderer: 'three' }],
63
+ },
64
+ });
65
+ ```
66
+
67
+ Options remain serializable data—there are no renderer callbacks—so the same
68
+ configuration is safe to reuse across compiler environments and caches.
49
69
 
50
70
  Rspack's dev server enables the loader's hot context. If you run a custom dev
51
71
  server, add Rspack's `HotModuleReplacementPlugin` as usual.
@@ -73,8 +93,43 @@ export default {
73
93
  ```
74
94
 
75
95
  With the loader-only form, configure `.tsrx` resolution, TypeScript stripping,
76
- and the exact server runtime alias yourself. The class plugin is recommended
77
- unless another integration owns those concerns.
96
+ and exact `octane$` / `octane/profiling$` aliases to the app's Octane package
97
+ yourself. The profiling alias keeps compiler metadata and runtime recording on
98
+ one module even when transformed raw dependencies carry a nested Octane copy.
99
+ You must also define Octane's
100
+ reserved profiling constant to the same boolean passed to the loader. Defining
101
+ `false` is important too: it lets production optimization erase the inactive
102
+ profiling runtime. The class plugin installs and owns this definition for you,
103
+ so do not configure it separately when using `OctaneRspackPlugin`.
104
+
105
+ ```js
106
+ import { rspack } from '@rspack/core';
107
+
108
+ const profiling = process.env.OCTANE_PROFILE === '1';
109
+
110
+ export default {
111
+ module: {
112
+ rules: [
113
+ {
114
+ test: /\.(?:tsrx|tsx|ts|js)$/,
115
+ enforce: 'pre',
116
+ type: 'javascript/auto',
117
+ use: {
118
+ loader: '@octanejs/rspack-plugin/loader',
119
+ options: { environment: 'client', profile: profiling },
120
+ },
121
+ },
122
+ ],
123
+ },
124
+ plugins: [
125
+ new rspack.DefinePlugin({
126
+ __OCTANE_PROFILE_ENABLED__: JSON.stringify(profiling),
127
+ }),
128
+ ],
129
+ };
130
+ ```
131
+
132
+ The class plugin is recommended unless another integration owns those concerns.
78
133
 
79
134
  ## App-level metadata
80
135
 
@@ -82,3 +137,8 @@ Transformed Rspack modules receive a serializable `buildInfo.octane` record
82
137
  containing `canonicalId`, `transformKind`, and `serverRpc`. App integrations can
83
138
  read the validated value with `getOctaneRspackBuildInfo(module)` without
84
139
  depending on compiler output parsing for module identity.
140
+
141
+ When a renderer is declared `server: 'client-only'`, client compilations also
142
+ emit `octane-client-references.json`. Its stable reference IDs map each omitted
143
+ server module to the JavaScript chunks that contain its browser implementation;
144
+ server compilations retain the same ID on the export-preserving inert stub.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@octanejs/rspack-plugin",
3
- "version": "0.1.0",
3
+ "version": "0.1.4",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "engines": {
@@ -45,13 +45,13 @@
45
45
  },
46
46
  "peerDependencies": {
47
47
  "@rspack/core": "^2.0.0",
48
- "octane": "0.1.5"
48
+ "octane": "0.1.9"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@rspack/core": "^2.1.3",
52
52
  "@types/node": "^24.3.0",
53
53
  "vitest": "^4.1.9",
54
- "octane": "0.1.5"
54
+ "octane": "0.1.9"
55
55
  },
56
56
  "scripts": {
57
57
  "test": "vitest run --config vitest.config.js"
package/src/loader.js CHANGED
@@ -1,8 +1,22 @@
1
- import { isAbsolute, resolve } from 'node:path';
1
+ import { realpathSync } from 'node:fs';
2
+ import { dirname, isAbsolute, resolve } from 'node:path';
2
3
  import remapping from '@jridgewell/remapping';
3
- import { canonicalModuleId, createOctaneCompiler } from 'octane/compiler/bundler';
4
+ import { canonicalModuleId, cleanModuleId, createOctaneCompiler } from 'octane/compiler/bundler';
4
5
  import { inferRspackEnvironment, normalizeLoaderOptions } from './shared.js';
5
6
 
7
+ function realRoot(path) {
8
+ try {
9
+ return realpathSync(path);
10
+ } catch {
11
+ return path;
12
+ }
13
+ }
14
+
15
+ function realModuleId(id) {
16
+ const file = cleanModuleId(id);
17
+ return realRoot(file) + id.slice(file.length);
18
+ }
19
+
6
20
  function clearBuildInfo(module) {
7
21
  if (module?.buildInfo && typeof module.buildInfo === 'object') {
8
22
  delete module.buildInfo.octane;
@@ -31,6 +45,33 @@ function composeSourceMaps(outputMap, inputSourceMap) {
31
45
  return String(chained.mappings).length > 0 ? chained : outputMap;
32
46
  }
33
47
 
48
+ async function resolveClientOnlyImports(context, compiler, source, id) {
49
+ if (typeof context.getResolve !== 'function') return [];
50
+ const requests = compiler.findServerImportRequests(String(source), id);
51
+ if (requests.length === 0) return [];
52
+ const resolver = context.getResolve({ dependencyType: 'esm' });
53
+ const issuer = dirname(cleanModuleId(id));
54
+ const classified = [];
55
+ await Promise.all(
56
+ requests.map(async (request) => {
57
+ let resolved;
58
+ try {
59
+ resolved = await resolver(issuer, request);
60
+ } catch {
61
+ // Rspack's normal dependency factory reports unresolved imports with its
62
+ // full request/issuer trace. Do not replace that diagnostic here.
63
+ return;
64
+ }
65
+ if (typeof resolved !== 'string') return;
66
+ const reference = compiler.clientReferenceForFile(resolved);
67
+ if (reference !== null) classified.push({ request, resolvedId: resolved, reference });
68
+ }),
69
+ );
70
+ return classified.sort((left, right) =>
71
+ left.request < right.request ? -1 : left.request > right.request ? 1 : 0,
72
+ );
73
+ }
74
+
34
75
  /**
35
76
  * Rspack's ESM loader entry. A compiler instance is intentionally scoped to
36
77
  * one invocation: Rspack owns output caching and invalidates it from the file
@@ -44,51 +85,92 @@ export default function octaneLoader(source, inputSourceMap) {
44
85
  try {
45
86
  const options = normalizeLoaderOptions(this.getOptions?.() ?? {});
46
87
  const loaderRoot = this.rootContext ?? process.cwd();
47
- const root = options.root
48
- ? isAbsolute(options.root)
49
- ? options.root
50
- : resolve(loaderRoot, options.root)
51
- : loaderRoot;
88
+ const root = realRoot(
89
+ options.root
90
+ ? isAbsolute(options.root)
91
+ ? options.root
92
+ : resolve(loaderRoot, options.root)
93
+ : loaderRoot,
94
+ );
52
95
  const environment = options.environment ?? inferRspackEnvironment(this.target);
53
96
  const hmr =
54
97
  environment === 'client' && this.hot === true && options.hmr !== false ? 'webpack' : false;
55
98
  const dev =
56
99
  environment === 'client' &&
57
100
  (options.dev ?? (this.mode === undefined || this.mode !== 'production'));
101
+ const profile = environment === 'client' && options.profile === true;
58
102
  const compiler = createOctaneCompiler({
59
103
  root,
104
+ profile,
60
105
  ...(options.exclude === undefined ? null : { exclude: options.exclude }),
61
- ...(options.parallelUse === undefined ? null : { parallelUse: options.parallelUse }),
62
- });
63
- const id = this.resource ?? this.resourcePath;
64
- const result = compiler.transform(String(source), id, {
65
- environment,
66
- hmr,
67
- dev,
68
- ...(options.parallelUse === undefined ? null : { parallelUse: options.parallelUse }),
106
+ ...(options.renderers === undefined ? null : { renderers: options.renderers }),
107
+ ...(options.requireDirective === undefined
108
+ ? null
109
+ : { requireDirective: options.requireDirective }),
110
+ // Ownership diagnostics surface through Rspack's own module warnings.
111
+ warn: (message) => this.emitWarning?.(new Error(message)),
69
112
  });
113
+ const id = realModuleId(this.resource ?? this.resourcePath);
114
+ const finish = (clientOnlyImports, callback) => {
115
+ try {
116
+ const result = compiler.transform(String(source), id, {
117
+ environment,
118
+ hmr,
119
+ dev,
120
+ profile,
121
+ ...(clientOnlyImports.length > 0 ? { clientOnlyImports } : null),
122
+ });
70
123
 
71
- if (result === null) {
72
- this.callback(null, source, this.sourceMap === false ? undefined : inputSourceMap);
73
- return;
74
- }
124
+ if (result === null) {
125
+ callback(null, source, this.sourceMap === false ? undefined : inputSourceMap);
126
+ return;
127
+ }
128
+
129
+ registerDependencies(this, result);
130
+ if (result.kind === 'none') {
131
+ callback(null, source, this.sourceMap === false ? undefined : inputSourceMap);
132
+ return;
133
+ }
134
+ setBuildInfo(this._module, {
135
+ canonicalId: canonicalModuleId(id, root),
136
+ transformKind: result.kind,
137
+ serverRpc:
138
+ result.kind === 'compile' &&
139
+ (result.code.includes('_$__serverRpc(') ||
140
+ result.code.includes('export const _$_server_$_')),
141
+ ...(result.clientReference === undefined
142
+ ? null
143
+ : { clientReference: { ...result.clientReference } }),
144
+ });
145
+ const sourceMap =
146
+ this.sourceMap === false ? undefined : composeSourceMaps(result.map, inputSourceMap);
147
+ callback(null, result.code, sourceMap);
148
+ } catch (error) {
149
+ callback(error instanceof Error ? error : new Error(String(error)));
150
+ }
151
+ };
75
152
 
76
- registerDependencies(this, result);
77
- if (result.kind === 'none') {
78
- this.callback(null, source, this.sourceMap === false ? undefined : inputSourceMap);
79
- return;
153
+ const callback = this.callback.bind(this);
154
+ const currentReference =
155
+ environment === 'server' && typeof compiler.clientReferenceForFile === 'function'
156
+ ? compiler.clientReferenceForFile(id)
157
+ : null;
158
+ if (
159
+ environment === 'server' &&
160
+ currentReference === null &&
161
+ typeof this.getResolve === 'function'
162
+ ) {
163
+ const requests = compiler.findServerImportRequests(String(source), id);
164
+ if (requests.length > 0) {
165
+ const asyncCallback = this.async?.() ?? callback;
166
+ resolveClientOnlyImports(this, compiler, source, id).then(
167
+ (imports) => finish(imports, asyncCallback),
168
+ (error) => asyncCallback(error instanceof Error ? error : new Error(String(error))),
169
+ );
170
+ return;
171
+ }
80
172
  }
81
- setBuildInfo(this._module, {
82
- canonicalId: canonicalModuleId(id, root),
83
- transformKind: result.kind,
84
- serverRpc:
85
- result.kind === 'compile' &&
86
- (result.code.includes('_$__serverRpc(') ||
87
- result.code.includes('export const _$_server_$_')),
88
- });
89
- const sourceMap =
90
- this.sourceMap === false ? undefined : composeSourceMaps(result.map, inputSourceMap);
91
- this.callback(null, result.code, sourceMap);
173
+ finish([], callback);
92
174
  } catch (error) {
93
175
  this.callback(error instanceof Error ? error : new Error(String(error)));
94
176
  }
package/src/plugin.js CHANGED
@@ -1,14 +1,34 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { realpathSync } from 'node:fs';
1
3
  import { createRequire } from 'node:module';
2
4
  import { isAbsolute, join, resolve } from 'node:path';
3
5
  import { fileURLToPath } from 'node:url';
4
- import { createOctaneCompiler } from 'octane/compiler/bundler';
5
- import { inferRspackEnvironment, normalizePluginOptions } from './shared.js';
6
+ import {
7
+ CLIENT_REFERENCE_MANIFEST_FILENAME,
8
+ createClientReferenceManifest,
9
+ createOctaneCompiler,
10
+ } from 'octane/compiler/bundler';
11
+ import {
12
+ getOctaneRspackBuildInfo,
13
+ inferRspackEnvironment,
14
+ normalizePluginOptions,
15
+ } from './shared.js';
6
16
 
7
17
  const PLUGIN_NAME = 'OctaneRspackPlugin';
18
+ const PROFILE_DEFINE = '__OCTANE_PROFILE_ENABLED__';
19
+ const PLUGIN_VERSION = createRequire(import.meta.url)('../package.json').version;
8
20
  const loaderPath = fileURLToPath(new URL('./loader.js', import.meta.url));
9
21
  const OCTANE_RULE = /\.(?:tsrx|tsx|ts|js)$/i;
10
22
  const TYPESCRIPT_RULE = /\.(?:tsrx|tsx|ts)$/i;
11
23
 
24
+ function realRoot(path) {
25
+ try {
26
+ return realpathSync(path);
27
+ } catch {
28
+ return path;
29
+ }
30
+ }
31
+
12
32
  function addUniqueExtensions(resolveOptions) {
13
33
  const extensions = resolveOptions.extensions ?? ['.js', '.json', '.wasm'];
14
34
  resolveOptions.extensions = [
@@ -33,14 +53,157 @@ function addRuntimeAlias(resolveOptions, request, root) {
33
53
  resolveOptions.alias = {
34
54
  ...aliases,
35
55
  octane$: resolveRuntimeModule(request, root),
56
+ // Compiler-emitted metadata imports this public subpath directly. Pin it
57
+ // alongside the runtime entry so raw/linked packages with their own nested
58
+ // Octane copy cannot split metadata registration from runtime recording.
59
+ 'octane/profiling$': resolveRuntimeModule('octane/profiling', root),
36
60
  };
37
61
  }
38
62
 
63
+ function projectRendererModule(request, root) {
64
+ // Renderer config uses project-root IDs such as `/src/object-renderer.ts`.
65
+ // They are never host-filesystem absolute paths, even if the same path happens
66
+ // to exist on a developer machine or inside a container.
67
+ return resolve(root, request.replace(/^[/\\]+/, ''));
68
+ }
69
+
70
+ function addProjectRendererAliases(resolveOptions, renderers, root) {
71
+ if (renderers === undefined) return;
72
+ const aliases = resolveOptions.alias === false ? {} : (resolveOptions.alias ?? {});
73
+ const additions = {};
74
+ for (const renderer of Object.values(renderers.registry)) {
75
+ if (!renderer.module.startsWith('/')) continue;
76
+ additions[`${renderer.module}$`] = projectRendererModule(renderer.module, root);
77
+ }
78
+ resolveOptions.alias = { ...aliases, ...additions };
79
+ }
80
+
39
81
  function addDependencies(collection, values) {
40
82
  if (!collection?.add) return;
41
83
  for (const value of values ?? []) collection.add(value);
42
84
  }
43
85
 
86
+ function iterable(value) {
87
+ return value && typeof value === 'object' && Symbol.iterator in value ? value : [];
88
+ }
89
+
90
+ function isJavaScriptAsset(filename) {
91
+ return (
92
+ /\.(?:c|m)?js(?:\?|$)/.test(filename) && !/\.hot-update\.(?:c|m)?js(?:\?|$)/.test(filename)
93
+ );
94
+ }
95
+
96
+ function moduleChunks(compilation, module, inherited) {
97
+ const chunks = new Set(inherited);
98
+ for (const chunk of iterable(compilation.chunkGraph.getModuleChunksIterable(module)))
99
+ chunks.add(chunk);
100
+ return chunks;
101
+ }
102
+
103
+ function visitClientReferenceModules(
104
+ compilation,
105
+ module,
106
+ inheritedChunks,
107
+ visit,
108
+ seen = new Set(),
109
+ ) {
110
+ if (!module || seen.has(module)) return;
111
+ seen.add(module);
112
+ const chunks = moduleChunks(compilation, module, inheritedChunks);
113
+ visit(module, chunks);
114
+ for (const child of iterable(module.modules)) {
115
+ visitClientReferenceModules(compilation, child, chunks, visit, seen);
116
+ }
117
+ if (module.rootModule) {
118
+ visitClientReferenceModules(compilation, module.rootModule, chunks, visit, seen);
119
+ }
120
+ }
121
+
122
+ /** Emit the client-only module identity mapped to its concrete browser chunks. */
123
+ function emitClientReferenceManifest(compiler, compilation) {
124
+ const entries = [];
125
+ for (const topLevelModule of iterable(compilation.modules)) {
126
+ visitClientReferenceModules(
127
+ compilation,
128
+ topLevelModule,
129
+ [],
130
+ (module, chunks) => {
131
+ const reference = getOctaneRspackBuildInfo(module)?.clientReference;
132
+ if (reference === undefined) return;
133
+ const files = new Set();
134
+ for (const chunk of chunks) {
135
+ for (const file of iterable(chunk?.files)) {
136
+ const filename = String(file);
137
+ if (isJavaScriptAsset(filename)) files.add(filename);
138
+ }
139
+ }
140
+ entries.push({ reference, chunks: files });
141
+ },
142
+ new Set(),
143
+ );
144
+ }
145
+ const manifest = createClientReferenceManifest(entries);
146
+ if (Object.keys(manifest.references).length === 0) return;
147
+ const source = JSON.stringify(manifest, null, 2) + '\n';
148
+ compilation.emitAsset(
149
+ CLIENT_REFERENCE_MANIFEST_FILENAME,
150
+ new compiler.webpack.sources.RawSource(source),
151
+ );
152
+ }
153
+
154
+ function defineMatchesBoolean(value, expected) {
155
+ return value === expected || value === JSON.stringify(expected);
156
+ }
157
+
158
+ function assertProfilingDefineAvailable(compiler, enabled) {
159
+ for (const plugin of compiler.options.plugins ?? []) {
160
+ // Rspack's DefinePlugin keeps its constructor argument in `_args`. Inspecting
161
+ // the configured plugin list catches conflicts regardless of apply order;
162
+ // otherwise DefinePlugin keeps the first value and emits only a warning,
163
+ // which can leave compiler metadata and runtime specialization out of sync.
164
+ const definitions = plugin?._args?.[0];
165
+ if (
166
+ definitions === null ||
167
+ typeof definitions !== 'object' ||
168
+ !Object.prototype.hasOwnProperty.call(definitions, PROFILE_DEFINE)
169
+ ) {
170
+ continue;
171
+ }
172
+ if (!defineMatchesBoolean(definitions[PROFILE_DEFINE], enabled)) {
173
+ throw new TypeError(
174
+ `@octanejs/rspack-plugin: ${PROFILE_DEFINE} is reserved by Octane and conflicts with \`profile: ${enabled}\`. Remove the custom DefinePlugin entry and configure profiling through OctaneRspackPlugin.`,
175
+ );
176
+ }
177
+ }
178
+ }
179
+
180
+ function installProfilingDefine(compiler, enabled) {
181
+ const DefinePlugin = compiler.webpack?.DefinePlugin;
182
+ if (typeof DefinePlugin !== 'function') {
183
+ throw new TypeError(
184
+ '@octanejs/rspack-plugin: this Rspack compiler does not expose webpack.DefinePlugin.',
185
+ );
186
+ }
187
+ new DefinePlugin({ [PROFILE_DEFINE]: JSON.stringify(enabled) }).apply(compiler);
188
+ }
189
+
190
+ function hasHotModuleReplacement(compiler) {
191
+ return (compiler.options.plugins ?? []).some(
192
+ (plugin) => plugin?.name === 'HotModuleReplacementPlugin',
193
+ );
194
+ }
195
+
196
+ function saltPersistentCacheVersion(compiler, inputs) {
197
+ const cache = compiler.options.cache;
198
+ if (cache === null || typeof cache !== 'object' || cache.type !== 'persistent') return;
199
+ const digest = createHash('sha256')
200
+ .update(JSON.stringify({ pluginVersion: PLUGIN_VERSION, ...inputs }))
201
+ .digest('hex')
202
+ .slice(0, 16);
203
+ const octaneVersion = `octane-rspack@${PLUGIN_VERSION}:${digest}`;
204
+ cache.version = cache.version ? `${cache.version}|${octaneVersion}` : octaneVersion;
205
+ }
206
+
44
207
  export class OctaneRspackPlugin {
45
208
  constructor(options = {}) {
46
209
  this.options = normalizePluginOptions(options);
@@ -50,36 +213,62 @@ export class OctaneRspackPlugin {
50
213
  apply(compiler) {
51
214
  const configuredRoot = this.options.root;
52
215
  const compilerRoot = compiler.options.context ?? process.cwd();
53
- const root = configuredRoot
54
- ? isAbsolute(configuredRoot)
55
- ? configuredRoot
56
- : resolve(compilerRoot, configuredRoot)
57
- : compilerRoot;
216
+ const root = realRoot(
217
+ configuredRoot
218
+ ? isAbsolute(configuredRoot)
219
+ ? configuredRoot
220
+ : resolve(compilerRoot, configuredRoot)
221
+ : compilerRoot,
222
+ );
58
223
  const environment = this.options.environment ?? inferRspackEnvironment(compiler.options.target);
224
+ const profile = environment === 'client' && this.options.profile === true;
225
+ const hmr =
226
+ environment === 'client' && hasHotModuleReplacement(compiler) && this.options.hmr !== false;
227
+ const dev =
228
+ environment === 'client' &&
229
+ (this.options.dev ??
230
+ (compiler.options.mode === undefined || compiler.options.mode !== 'production'));
231
+ assertProfilingDefineAvailable(compiler, profile);
232
+ saltPersistentCacheVersion(compiler, {
233
+ root,
234
+ environment,
235
+ hmr,
236
+ dev,
237
+ profile,
238
+ exclude: this.options.exclude ?? [],
239
+ renderers: this.options.renderers?.signature,
240
+ // Ownership flips which modules compile vs pass through — cached
241
+ // transform results must not survive a requireDirective toggle.
242
+ requireDirective: this.options.requireDirective === true,
243
+ transpile: this.options.transpile !== false,
244
+ });
245
+ installProfilingDefine(compiler, profile);
59
246
  const neutralCompiler = createOctaneCompiler({
60
247
  root,
248
+ profile,
61
249
  ...(this.options.exclude === undefined ? null : { exclude: this.options.exclude }),
62
- ...(this.options.parallelUse === undefined
63
- ? null
64
- : { parallelUse: this.options.parallelUse }),
250
+ ...(this.options.renderers === undefined ? null : { renderers: this.options.renderers }),
65
251
  });
66
252
  const runtimeRequest = neutralCompiler.resolveRuntimeRequest('octane', environment);
67
253
 
68
254
  compiler.options.resolve ??= {};
69
255
  addUniqueExtensions(compiler.options.resolve);
70
256
  addRuntimeAlias(compiler.options.resolve, runtimeRequest, root);
257
+ addProjectRendererAliases(compiler.options.resolve, this.options.renderers, root);
71
258
 
72
259
  compiler.options.module ??= {};
73
260
  compiler.options.module.rules ??= [];
74
261
  const loaderOptions = {
75
262
  root,
76
263
  environment,
264
+ profile,
77
265
  ...(this.options.hmr === undefined ? null : { hmr: this.options.hmr }),
78
266
  ...(this.options.dev === undefined ? null : { dev: this.options.dev }),
79
- ...(this.options.parallelUse === undefined
80
- ? null
81
- : { parallelUse: this.options.parallelUse }),
82
267
  ...(this.options.exclude === undefined ? null : { exclude: this.options.exclude }),
268
+ ...(this.options.renderers === undefined ? null : { renderers: this.options.renderers }),
269
+ ...(this.options.requireDirective === undefined
270
+ ? null
271
+ : { requireDirective: this.options.requireDirective }),
83
272
  };
84
273
  compiler.options.module.rules.push({
85
274
  test: OCTANE_RULE,
@@ -115,6 +304,15 @@ export class OctaneRspackPlugin {
115
304
  const current = discover();
116
305
  addDependencies(compilation.fileDependencies, current.dependencies);
117
306
  addDependencies(compilation.missingDependencies, current.missingDependencies);
307
+ if (environment === 'client') {
308
+ compilation.hooks.processAssets.tap(
309
+ {
310
+ name: PLUGIN_NAME,
311
+ stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_REPORT,
312
+ },
313
+ () => emitClientReferenceManifest(compiler, compilation),
314
+ );
315
+ }
118
316
  });
119
317
  }
120
318
  }
package/src/shared.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { normalizeRendererConfig } from 'octane/compiler/renderers';
2
+
1
3
  const CLIENT_TARGETS = new Set(['web', 'webworker', 'electron-renderer', 'browserslist']);
2
4
 
3
5
  function targetValues(target) {
@@ -32,7 +34,16 @@ export function inferRspackEnvironment(target) {
32
34
  return 'client';
33
35
  }
34
36
 
35
- const LOADER_OPTION_KEYS = new Set(['root', 'environment', 'hmr', 'dev', 'parallelUse', 'exclude']);
37
+ const LOADER_OPTION_KEYS = new Set([
38
+ 'root',
39
+ 'environment',
40
+ 'hmr',
41
+ 'dev',
42
+ 'profile',
43
+ 'exclude',
44
+ 'renderers',
45
+ 'requireDirective',
46
+ ]);
36
47
  const PLUGIN_OPTION_KEYS = new Set([...LOADER_OPTION_KEYS, 'transpile']);
37
48
 
38
49
  function assertBooleanOption(options, key) {
@@ -64,7 +75,8 @@ function normalizeOptions(value, plugin) {
64
75
  }
65
76
  assertBooleanOption(options, 'hmr');
66
77
  assertBooleanOption(options, 'dev');
67
- assertBooleanOption(options, 'parallelUse');
78
+ assertBooleanOption(options, 'profile');
79
+ assertBooleanOption(options, 'requireDirective');
68
80
  if (
69
81
  options.exclude !== undefined &&
70
82
  (!Array.isArray(options.exclude) || options.exclude.some((entry) => typeof entry !== 'string'))
@@ -72,14 +84,20 @@ function normalizeOptions(value, plugin) {
72
84
  throw new TypeError('@octanejs/rspack-plugin: `exclude` must be an array of path strings.');
73
85
  }
74
86
  if (plugin) assertBooleanOption(options, 'transpile');
87
+ const renderers =
88
+ options.renderers === undefined ? undefined : normalizeRendererConfig(options.renderers);
75
89
 
76
90
  const normalized = {
77
91
  ...(options.root === undefined ? null : { root: options.root }),
78
92
  ...(options.environment === undefined ? null : { environment: options.environment }),
79
93
  ...(options.hmr === undefined ? null : { hmr: options.hmr }),
80
94
  ...(options.dev === undefined ? null : { dev: options.dev }),
81
- ...(options.parallelUse === undefined ? null : { parallelUse: options.parallelUse }),
95
+ ...(options.profile === undefined ? null : { profile: options.profile }),
82
96
  ...(options.exclude === undefined ? null : { exclude: [...options.exclude] }),
97
+ ...(renderers === undefined ? null : { renderers }),
98
+ ...(options.requireDirective === undefined
99
+ ? null
100
+ : { requireDirective: options.requireDirective }),
83
101
  ...(plugin && options.transpile !== undefined ? { transpile: options.transpile } : null),
84
102
  };
85
103
  if (normalized.exclude) Object.freeze(normalized.exclude);
@@ -97,12 +115,21 @@ export function normalizePluginOptions(value) {
97
115
  /** Read the serializable metadata attached to an Octane-transformed module. */
98
116
  export function getOctaneRspackBuildInfo(module) {
99
117
  const value = module?.buildInfo?.octane;
118
+ const nestedReferenceValid =
119
+ value?.clientReference !== null &&
120
+ typeof value?.clientReference === 'object' &&
121
+ typeof value.clientReference.id === 'string' &&
122
+ typeof value.clientReference.moduleId === 'string' &&
123
+ typeof value.clientReference.renderer === 'string';
100
124
  if (
101
125
  value &&
102
126
  typeof value === 'object' &&
103
127
  typeof value.canonicalId === 'string' &&
104
- (value.transformKind === 'compile' || value.transformKind === 'slots') &&
105
- typeof value.serverRpc === 'boolean'
128
+ (value.transformKind === 'compile' ||
129
+ value.transformKind === 'slots' ||
130
+ value.transformKind === 'client-only-stub') &&
131
+ typeof value.serverRpc === 'boolean' &&
132
+ (value.clientReference === undefined || nestedReferenceValid)
106
133
  ) {
107
134
  return value;
108
135
  }
package/types/index.d.ts CHANGED
@@ -2,6 +2,70 @@ import type { Compiler, RspackPluginInstance } from '@rspack/core';
2
2
 
3
3
  export type OctaneRspackEnvironment = 'client' | 'server';
4
4
 
5
+ export interface OctaneRendererRuleOptions {
6
+ /** Glob or globs matched against canonical project-relative module IDs. */
7
+ include: string | readonly string[];
8
+ /** Optional glob or globs that remove files from this rule. */
9
+ exclude?: string | readonly string[];
10
+ /** Renderer alias declared in `registry`, or the built-in `dom` alias. */
11
+ renderer: string;
12
+ }
13
+
14
+ export type OctaneRendererRegistryEntry =
15
+ | string
16
+ | {
17
+ module: string;
18
+ target?: 'dom' | 'universal';
19
+ server?: 'render' | 'client-only' | 'unsupported';
20
+ intrinsics?: string;
21
+ text?: 'reject' | 'ignore' | 'host';
22
+ capabilities?: readonly string[];
23
+ };
24
+
25
+ /** Static metadata for a component prop lowered for another renderer. */
26
+ export interface OctaneRendererBoundaryOptions {
27
+ ownerRenderer: string;
28
+ childRenderer: string;
29
+ prop: string;
30
+ server?: 'omit-child';
31
+ }
32
+
33
+ /** @experimental Declarative renderer selection shared with other Octane compilers. */
34
+ export interface OctaneRendererConfigOptions {
35
+ registry?: Readonly<Record<string, OctaneRendererRegistryEntry>>;
36
+ /** Boundary metadata keyed by stable module ID and export name. */
37
+ boundaries?: Readonly<Record<string, Readonly<Record<string, OctaneRendererBoundaryOptions>>>>;
38
+ default?: string;
39
+ rules?: readonly OctaneRendererRuleOptions[];
40
+ }
41
+
42
+ /** Canonical renderer configuration accepted when another Octane integration resolved it. */
43
+ export interface OctaneResolvedRendererConfig {
44
+ readonly registry: Readonly<
45
+ Record<
46
+ string,
47
+ {
48
+ readonly module: string;
49
+ readonly target: 'dom' | 'universal';
50
+ readonly server: 'render' | 'client-only' | 'unsupported';
51
+ readonly intrinsics?: string;
52
+ readonly text: 'reject' | 'ignore' | 'host';
53
+ readonly capabilities: readonly string[];
54
+ }
55
+ >
56
+ >;
57
+ readonly boundaries: Readonly<
58
+ Record<string, Readonly<Record<string, Readonly<OctaneRendererBoundaryOptions>>>>
59
+ >;
60
+ readonly default: string;
61
+ readonly rules: readonly {
62
+ readonly include: readonly string[];
63
+ readonly exclude: readonly string[];
64
+ readonly renderer: string;
65
+ }[];
66
+ readonly signature: string;
67
+ }
68
+
5
69
  export interface OctaneRspackLoaderOptions {
6
70
  /** Project root used to canonicalize module IDs and discover package manifests. */
7
71
  root?: string;
@@ -11,10 +75,28 @@ export interface OctaneRspackLoaderOptions {
11
75
  hmr?: boolean;
12
76
  /** Emit client development metadata. Defaults to Rspack's non-production mode. */
13
77
  dev?: boolean;
14
- /** Enable Octane's parallel `use()` compilation pipeline. Default `true`. */
15
- parallelUse?: boolean;
16
- /** Path fragments excluded from the plain `.ts`/`.js` hook-slot pass. */
78
+ /** Emit client profiling metadata and enable the profiling runtime. Default `false`. */
79
+ profile?: boolean;
80
+ /**
81
+ * Path fragments excluded from the plain `.ts`/`.js` hook-slot pass. With
82
+ * `requireDirective`, excluded paths are exempt from Octane ownership
83
+ * entirely — including `.tsrx`/`.tsx` — for projects routing those paths
84
+ * through a different tsrx compiler (e.g. `@tsrx/react`).
85
+ */
17
86
  exclude?: string[];
87
+ /** @experimental Renderer registry and ordered per-file selection rules. */
88
+ renderers?: OctaneRendererConfigOptions | OctaneResolvedRendererConfig;
89
+ /**
90
+ * Mixed-toolchain ownership gate: when `true`, Octane compiles only
91
+ * project modules that declare `'use octane'` in their directive prologue.
92
+ * Undirected project `.tsx`/`.ts`/`.js` pass through to the host
93
+ * framework's own pipeline; an undirected project `.tsrx` is a build
94
+ * error. Installed and linked packages keep their Octane package-manifest
95
+ * decision. The directive is always tolerated and stripped from compiled
96
+ * output, even when this option is off.
97
+ * @default false
98
+ */
99
+ requireDirective?: boolean;
18
100
  }
19
101
 
20
102
  export interface OctaneRspackPluginOptions extends OctaneRspackLoaderOptions {
@@ -28,8 +110,14 @@ export interface OctaneRspackPluginOptions extends OctaneRspackLoaderOptions {
28
110
 
29
111
  export interface OctaneRspackBuildInfo {
30
112
  canonicalId: string;
31
- transformKind: 'compile' | 'slots';
113
+ transformKind: 'compile' | 'slots' | 'client-only-stub';
32
114
  serverRpc: boolean;
115
+ /** Stable identity shared by the client compile and its inert server stub. */
116
+ clientReference?: {
117
+ readonly id: string;
118
+ readonly moduleId: string;
119
+ readonly renderer: string;
120
+ };
33
121
  }
34
122
 
35
123
  export declare class OctaneRspackPlugin implements RspackPluginInstance {