@octanejs/rspack-plugin 0.1.36 → 0.1.38

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
@@ -83,9 +83,21 @@ new OctaneRspackPlugin({
83
83
  });
84
84
  ```
85
85
 
86
- Options remain serializable data—there are no renderer callbacks—so the same
86
+ Renderer options remain serializable data—there are no renderer callbacks—so the same
87
87
  configuration is safe to reuse across compiler environments and caches.
88
88
 
89
+ For one-shot production builds, the experimental class-plugin option
90
+ `cssModuleConstants: true` folds proven named-string exports from JavaScript
91
+ CSS-module providers such as `css-loader` with CSS extraction. An immutable CSS
92
+ provider can instead supply a `cssModuleConstants(module)` callback. It runs on
93
+ the main thread and its facts are checked against the exact completed loader
94
+ source. Ordinary mutable default maps and native `css/module` are left alone.
95
+ Eligible consumers are compiled once more and are not stored in the persistent
96
+ module cache; other modules keep normal caching. The option is disabled by
97
+ default and does not change development, HMR, or watch output. See
98
+ [CSS-module constants](../../docs/compiler-css-module-constants.md) for the
99
+ provider contract and stylesheet-ownership rules.
100
+
89
101
  Rspack layers can compile the same authored module against distinct universal
90
102
  renderer graphs. Configure the background graph at the top level, then key
91
103
  `layerSpecializations` by the exact value of `module.layer`:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@octanejs/rspack-plugin",
3
- "version": "0.1.36",
3
+ "version": "0.1.38",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "engines": {
@@ -45,13 +45,16 @@
45
45
  },
46
46
  "peerDependencies": {
47
47
  "@rspack/core": "^2.0.0",
48
- "octane": "0.1.41"
48
+ "octane": "0.1.43"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@rspack/core": "^2.1.4",
52
+ "@types/jsdom": "30.0.0",
52
53
  "@types/node": "^24.13.3",
54
+ "css-loader": "6.11.0",
55
+ "jsdom": "^30.0.1",
53
56
  "vitest": "^4.1.10",
54
- "octane": "0.1.41"
57
+ "octane": "0.1.43"
55
58
  },
56
59
  "scripts": {
57
60
  "test": "vitest run --config vitest.config.js"
@@ -0,0 +1,377 @@
1
+ import {
2
+ cleanModuleId,
3
+ isPlainCssModuleId,
4
+ readCssModuleExports,
5
+ validateCssModuleConstants,
6
+ } from 'octane/compiler/bundler';
7
+ import {
8
+ CSS_MODULE_BUILD_INFO_KEY,
9
+ CSS_MODULE_CONTEXT_KEY,
10
+ cssModuleSourceHash,
11
+ } from './css-module-data.js';
12
+
13
+ const PLUGIN_NAME = 'OctaneRspackCssModuleConstants';
14
+ const DIAGNOSTIC_OWNER = '@octanejs/rspack-plugin';
15
+ const JAVASCRIPT_TYPES = new Set(['javascript/auto', 'javascript/esm']);
16
+ const HASH = /^[a-f0-9]{64}$/;
17
+
18
+ function iterable(value) {
19
+ return value != null && typeof value[Symbol.iterator] === 'function' ? value : [];
20
+ }
21
+
22
+ function identifier(module) {
23
+ if (typeof module?.identifier !== 'function') return null;
24
+ const id = module.identifier();
25
+ return typeof id === 'string' ? id : null;
26
+ }
27
+
28
+ function moduleSource(module) {
29
+ const source = module?.originalSource?.()?.source();
30
+ if (typeof source === 'string') return source;
31
+ return Buffer.isBuffer(source) ? source.toString('utf8') : null;
32
+ }
33
+
34
+ /** Reacquire current-build objects; native-backed Modules never enter our state. */
35
+ function currentModules(modules) {
36
+ const result = new Map();
37
+ const seen = new Set();
38
+ const visit = (module) => {
39
+ if (module == null || seen.has(module)) return;
40
+ seen.add(module);
41
+ // Concatenation can move a provider under another module. Prefer the
42
+ // original child if a wrapper happens to share its identifier.
43
+ for (const child of iterable(module.modules)) visit(child);
44
+ if (module.rootModule != null) visit(module.rootModule);
45
+ const id = identifier(module);
46
+ if (id !== null && !result.has(id)) result.set(id, module);
47
+ };
48
+ for (const module of iterable(modules)) visit(module);
49
+ return result;
50
+ }
51
+
52
+ function candidateInfo(module) {
53
+ const info = module?.buildInfo?.[CSS_MODULE_BUILD_INFO_KEY];
54
+ if (
55
+ info == null ||
56
+ typeof info.sourceHash !== 'string' ||
57
+ !HASH.test(info.sourceHash) ||
58
+ !Array.isArray(info.requests) ||
59
+ !info.requests.every((request) => typeof request === 'string') ||
60
+ !Array.isArray(info.consumed) ||
61
+ !info.consumed.every((request) => typeof request === 'string')
62
+ ) {
63
+ return null;
64
+ }
65
+ return info;
66
+ }
67
+
68
+ function changed(importer, request, reason) {
69
+ const location =
70
+ request === undefined
71
+ ? JSON.stringify(importer)
72
+ : `${JSON.stringify(importer)} (${JSON.stringify(request)})`;
73
+ throw new Error(`${DIAGNOSTIC_OWNER}: CSS-module proof changed for ${location}: ${reason}.`);
74
+ }
75
+
76
+ /**
77
+ * A resource path is not an import identity: issuer rules, layers, queries,
78
+ * dependency categories, and replacements can select different modules. Read
79
+ * the effective target of the actual ESM edge after the make phase instead.
80
+ */
81
+ function targetForRequest(compilation, importer, request) {
82
+ const targets = new Map();
83
+ for (const connection of compilation.moduleGraph.getOutgoingConnections(importer)) {
84
+ const dependency = connection.dependency;
85
+ if (dependency?.request !== request || dependency.category !== 'esm') continue;
86
+ if (dependency.attributes != null && Object.keys(dependency.attributes).length > 0) {
87
+ return null;
88
+ }
89
+ const target = connection.module;
90
+ const id = identifier(target);
91
+ if (id === null) return null;
92
+ targets.set(id, target);
93
+ }
94
+ return targets.size === 1 ? targets.values().next().value : null;
95
+ }
96
+
97
+ function sortedStrings(values) {
98
+ return [...new Set([...iterable(values)].filter((value) => typeof value === 'string'))].sort();
99
+ }
100
+
101
+ function moduleDependencies(module) {
102
+ const info = module.buildInfo;
103
+ return {
104
+ files: sortedStrings(info?.fileDependencies),
105
+ contexts: sortedStrings(info?.contextDependencies),
106
+ missing: sortedStrings(info?.missingDependencies),
107
+ build: sortedStrings(info?.buildDependencies),
108
+ };
109
+ }
110
+
111
+ function providerMeta(module) {
112
+ const info = module.buildInfo;
113
+ return Object.freeze(info != null && typeof info === 'object' ? { ...info } : {});
114
+ }
115
+
116
+ function readTargetProof(module, option, environment, cache) {
117
+ const id = identifier(module);
118
+ if (id === null || !JAVASCRIPT_TYPES.has(module.type) || typeof module.resource !== 'string') {
119
+ // Native css/* has no final JavaScript export map at this stage. Likewise
120
+ // asset/source can contain ESM-looking text without exporting its bindings.
121
+ return null;
122
+ }
123
+ const code = moduleSource(module);
124
+ if (code === null) return null;
125
+ const fingerprint = cssModuleSourceHash(code);
126
+ const cached = cache.get(id);
127
+ if (cached !== undefined) {
128
+ if (cached.fingerprint !== fingerprint)
129
+ changed(id, undefined, 'provider source changed during collection');
130
+ return cached.proof;
131
+ }
132
+ const exports = readCssModuleExports(code, { allowPureVar: true });
133
+ const supplied =
134
+ typeof option !== 'function'
135
+ ? null
136
+ : validateCssModuleConstants(
137
+ option(
138
+ Object.freeze({
139
+ id,
140
+ resource: module.resource,
141
+ code,
142
+ meta: providerMeta(module),
143
+ environment,
144
+ layer: module.layer,
145
+ type: module.type,
146
+ }),
147
+ ),
148
+ exports,
149
+ id,
150
+ DIAGNOSTIC_OWNER,
151
+ );
152
+ const named =
153
+ isPlainCssModuleId(cleanModuleId(module.resource)) && exports?.pure === true
154
+ ? new Map(exports.named)
155
+ : new Map();
156
+ for (const [name, value] of supplied?.named ?? []) named.set(name, value);
157
+ const defaultMap = supplied?.default ?? new Map();
158
+ const proof =
159
+ named.size === 0 && defaultMap.size === 0
160
+ ? null
161
+ : {
162
+ id,
163
+ fingerprint,
164
+ named: [...named].sort(([left], [right]) => left.localeCompare(right)),
165
+ default: [...defaultMap].sort(([left], [right]) => left.localeCompare(right)),
166
+ dependencies: moduleDependencies(module),
167
+ };
168
+ cache.set(id, { fingerprint, proof });
169
+ return proof;
170
+ }
171
+
172
+ function sameStrings(left, right) {
173
+ const a = [...new Set(left)].sort();
174
+ const b = [...new Set(right)].sort();
175
+ return a.length === b.length && a.every((value, index) => value === b[index]);
176
+ }
177
+
178
+ function verifyTarget(compilation, importer, entry) {
179
+ const target = targetForRequest(compilation, importer, entry.request);
180
+ if (identifier(target) !== entry.id)
181
+ changed(identifier(importer), entry.request, 'the effective module identity differs');
182
+ if (!JAVASCRIPT_TYPES.has(target.type))
183
+ changed(identifier(importer), entry.request, 'the target is no longer JavaScript ESM');
184
+ const source = moduleSource(target);
185
+ if (source === null || cssModuleSourceHash(source) !== entry.fingerprint) {
186
+ changed(identifier(importer), entry.request, 'the final provider source differs');
187
+ }
188
+ }
189
+
190
+ function verifyGraph(compilation, state) {
191
+ if (state.receipts.size === 0) return;
192
+ const modules = currentModules(compilation.modules);
193
+ for (const [id, receipt] of state.receipts) {
194
+ const importer = modules.get(id);
195
+ const info = candidateInfo(importer);
196
+ if (
197
+ info === null ||
198
+ info.sourceHash !== receipt.sourceHash ||
199
+ !sameStrings(
200
+ info.consumed,
201
+ receipt.imports.map((entry) => entry.request),
202
+ )
203
+ ) {
204
+ changed(id, undefined, 'the authored importer or committed-use receipt differs');
205
+ }
206
+ for (const entry of receipt.imports) verifyTarget(compilation, importer, entry);
207
+ }
208
+ }
209
+
210
+ function verifyFinalSources(compilation, state) {
211
+ if (state.receipts.size === 0) return;
212
+ const modules = currentModules(compilation.modules);
213
+ const checked = new Map();
214
+ for (const receipt of state.receipts.values()) {
215
+ for (const entry of receipt.imports) {
216
+ if (checked.has(entry.id)) {
217
+ if (checked.get(entry.id) !== entry.fingerprint)
218
+ changed(entry.id, undefined, 'conflicting source receipts');
219
+ continue;
220
+ }
221
+ checked.set(entry.id, entry.fingerprint);
222
+ const module = modules.get(entry.id);
223
+ // The full graph was checked at seal, before optimization. An unused
224
+ // component and its stylesheet may now be gone, which is intentional.
225
+ if (module === undefined) continue;
226
+ const source = moduleSource(module);
227
+ if (
228
+ !JAVASCRIPT_TYPES.has(module.type) ||
229
+ source === null ||
230
+ cssModuleSourceHash(source) !== entry.fingerprint
231
+ ) {
232
+ changed(entry.id, undefined, 'the emitted provider source differs');
233
+ }
234
+ }
235
+ }
236
+ }
237
+
238
+ async function collectAndRebuild(compilation, state, option, environment) {
239
+ if (state.started) return;
240
+ state.started = true;
241
+ const cache = new Map();
242
+ // Collect from the complete first graph before rebuilding anything. This is
243
+ // both deterministic and safe for mutually importing virtual CSS providers.
244
+ {
245
+ const modules = currentModules(compilation.modules);
246
+ const candidates = [...modules]
247
+ .filter(([, module]) => candidateInfo(module)?.requests.length > 0)
248
+ .map(([id]) => id)
249
+ .sort();
250
+ for (const id of candidates) {
251
+ const importer = modules.get(id);
252
+ const info = candidateInfo(importer);
253
+ const imports = [];
254
+ for (const request of [...new Set(info.requests)].sort()) {
255
+ const target = targetForRequest(compilation, importer, request);
256
+ if (target === null) continue;
257
+ const proof = readTargetProof(target, option, environment, cache);
258
+ if (proof !== null) imports.push({ request, ...proof });
259
+ }
260
+ if (imports.length > 0) {
261
+ state.proofs.set(id, { sourceHash: info.sourceHash, imports });
262
+ }
263
+ }
264
+ }
265
+ // A derived CSS provider can itself be an Octane importer. Its first-pass
266
+ // source is not a stable proof if this batch will recompile it. Deopt those
267
+ // edges rather than chase a fixed point or rebuild any importer twice. This
268
+ // deliberately bounded pass need not find every possible constant.
269
+ const scheduled = new Set(state.proofs.keys());
270
+ for (const [id, proof] of state.proofs) {
271
+ const imports = proof.imports.filter((entry) => !scheduled.has(entry.id));
272
+ if (imports.length === 0) state.proofs.delete(id);
273
+ else if (imports.length !== proof.imports.length) state.proofs.set(id, { ...proof, imports });
274
+ }
275
+ // Rspack owns graph mutation. Do not write source objects, synthesize entry
276
+ // dependencies, or invoke importModule (which executes application modules).
277
+ // Its public rebuildModule dispatcher batches synchronous requests. Ignore
278
+ // callback Module values: Rspack may return those in a different order, and
279
+ // only the current graph's exact identifiers authenticate the rebuilt inputs.
280
+ const pending = (() => {
281
+ const modules = currentModules(compilation.modules);
282
+ return [...state.proofs.keys()].map((id) => {
283
+ const importer = modules.get(id);
284
+ if (importer === undefined)
285
+ changed(id, undefined, 'the importer disappeared before rebuilding');
286
+ return new Promise((resolve, reject) => {
287
+ compilation.rebuildModule(importer, (error) => (error ? reject(error) : resolve()));
288
+ });
289
+ });
290
+ })();
291
+ const results = await Promise.allSettled(pending);
292
+ for (const result of results) if (result.status === 'rejected') throw result.reason;
293
+ const rebuiltModules = currentModules(compilation.modules);
294
+ for (const [id, proof] of state.proofs) {
295
+ const rebuilt = rebuiltModules.get(id);
296
+ const info = candidateInfo(rebuilt);
297
+ if (info === null || info.sourceHash !== proof.sourceHash) {
298
+ changed(id, undefined, 'the authored source changed while rebuilding');
299
+ }
300
+ const entries = new Map(proof.imports.map((entry) => [entry.request, entry]));
301
+ const consumed = [...new Set(info.consumed)].sort();
302
+ if (consumed.some((request) => !entries.has(request))) {
303
+ changed(id, undefined, 'the compiler consumed an unprovided import');
304
+ }
305
+ if (consumed.length > 0) {
306
+ state.receipts.set(id, {
307
+ sourceHash: proof.sourceHash,
308
+ imports: consumed.map((request) => entries.get(request)),
309
+ });
310
+ }
311
+ }
312
+ verifyGraph(compilation, state);
313
+ }
314
+
315
+ function oneShotProduction(compiler) {
316
+ return (
317
+ compiler.options.mode === 'production' && compiler.watchMode !== true && !compiler.options.watch
318
+ );
319
+ }
320
+
321
+ /** Install the opt-in, main-thread, same-compilation CSS proof controller. */
322
+ export function installCssModuleConstants(compiler, { option, environment }) {
323
+ if (option === undefined || option === false) return;
324
+ if (option !== true && typeof option !== 'function') {
325
+ throw new TypeError(`${DIAGNOSTIC_OWNER}: cssModuleConstants must be a boolean or function.`);
326
+ }
327
+ const NormalModule = compiler.webpack?.NormalModule;
328
+ if (
329
+ typeof NormalModule?.getCompilationHooks !== 'function' ||
330
+ typeof compiler.hooks.thisCompilation?.tap !== 'function' ||
331
+ typeof compiler.hooks.finishMake?.tapPromise !== 'function'
332
+ ) {
333
+ throw new TypeError(
334
+ `${DIAGNOSTIC_OWNER}: cssModuleConstants requires Rspack's module and finishMake hooks.`,
335
+ );
336
+ }
337
+ const states = new WeakMap();
338
+ compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => {
339
+ const state = {
340
+ enabled: compiler.options.mode === 'production',
341
+ discoverOnly: !oneShotProduction(compiler),
342
+ started: false,
343
+ proofs: new Map(),
344
+ receipts: new Map(),
345
+ };
346
+ states.set(compilation, state);
347
+ if (!state.enabled) return;
348
+ NormalModule.getCompilationHooks(compilation).loader.tap(PLUGIN_NAME, (context, module) => {
349
+ context[CSS_MODULE_CONTEXT_KEY] = {
350
+ enabled: true,
351
+ ...(state.discoverOnly ? { discoverOnly: true } : null),
352
+ proof: state.discoverOnly ? null : (state.proofs.get(identifier(module)) ?? null),
353
+ };
354
+ });
355
+ // Production watch may cache ordinary candidate metadata for a later
356
+ // one-shot build, but never authenticates or consumes cross-module facts.
357
+ if (state.discoverOnly) return;
358
+ // All finishModules taps have completed, and module concatenation has not
359
+ // yet rewritten the effective graph. This catches later provider rebuilds.
360
+ compilation.hooks.seal.tap({ name: PLUGIN_NAME, stage: Number.MAX_SAFE_INTEGER }, () =>
361
+ verifyGraph(compilation, state),
362
+ );
363
+ compilation.hooks.processAssets.tap(
364
+ {
365
+ name: PLUGIN_NAME,
366
+ stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_REPORT,
367
+ },
368
+ () => verifyFinalSources(compilation, state),
369
+ );
370
+ });
371
+ compiler.hooks.finishMake.tapPromise(PLUGIN_NAME, async (compilation) => {
372
+ const state = states.get(compilation);
373
+ if (state?.enabled && !state.discoverOnly && oneShotProduction(compiler)) {
374
+ await collectAndRebuild(compilation, state, option, environment);
375
+ }
376
+ });
377
+ }
@@ -0,0 +1,90 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ // Only plain data crosses Rspack's worker boundary. The provider callback and
4
+ // native module graph stay on the main thread for the current compilation.
5
+ export const CSS_MODULE_CONTEXT_KEY = '__octaneCssModuleConstants';
6
+ export const CSS_MODULE_BUILD_INFO_KEY = 'octaneCssModuleConstants';
7
+
8
+ export function cssModuleSourceHash(source) {
9
+ return createHash('sha256').update(String(source)).digest('hex');
10
+ }
11
+
12
+ export function clearCssModuleBuildInfo(module) {
13
+ if (module?.buildInfo && typeof module.buildInfo === 'object') {
14
+ delete module.buildInfo[CSS_MODULE_BUILD_INFO_KEY];
15
+ }
16
+ }
17
+
18
+ /** Prepare a proof for this exact authored loader input, never a previous build. */
19
+ export function prepareCssModuleConstants(context, compiler, source, id, options) {
20
+ const data = context[CSS_MODULE_CONTEXT_KEY];
21
+ if (
22
+ data?.enabled !== true ||
23
+ context.mode !== 'production' ||
24
+ context.hot === true ||
25
+ options.dev ||
26
+ options.hmr
27
+ ) {
28
+ return null;
29
+ }
30
+ const requests = compiler.findCssModuleImportRequests(source, id, options.environment);
31
+ if (requests.length === 0) return null;
32
+ // A host callback may close over configuration outside Rspack's cache key.
33
+ // Only eligible CSS consumers pay for the conservative one-shot rebuild.
34
+ if (data.discoverOnly !== true) context.cacheable?.(false);
35
+ const sourceHash = cssModuleSourceHash(source);
36
+ const requested = new Set(requests);
37
+ const imports = new Map();
38
+ if (data.discoverOnly !== true && data.proof?.sourceHash === sourceHash) {
39
+ for (const entry of data.proof.imports) {
40
+ if (!requested.has(entry.request)) continue;
41
+ imports.set(entry.request, {
42
+ ...entry,
43
+ named: new Map(entry.named),
44
+ default: new Map(entry.default),
45
+ });
46
+ }
47
+ }
48
+ const transformOptions =
49
+ imports.size === 0
50
+ ? null
51
+ : {
52
+ resolveCssModuleConstant(request, imported, property) {
53
+ const entry = imports.get(request);
54
+ if (entry === undefined) return undefined;
55
+ if (property === null) return entry.named.get(imported);
56
+ if (imported === 'default') {
57
+ return entry.default.get(property);
58
+ }
59
+ if (imported === '*') {
60
+ return entry.named.get(property);
61
+ }
62
+ return undefined;
63
+ },
64
+ // Keep each retained template's ordinary stylesheet ownership edge.
65
+ preserveCssModuleReferences: [...imports.keys()],
66
+ };
67
+ return { sourceHash, requests, imports, transformOptions };
68
+ }
69
+
70
+ /** Publish only committed folds and their real loader dependencies. */
71
+ export function finishCssModuleConstants(context, prepared, result) {
72
+ if (prepared === null || result?.kind !== 'compile' || !context._module) return;
73
+ const consumed = [...new Set(result.cssModuleConstantImports ?? [])].filter((request) =>
74
+ prepared.imports.has(request),
75
+ );
76
+ for (const request of consumed) {
77
+ const dependencies = prepared.imports.get(request).dependencies;
78
+ for (const file of dependencies.files) context.addDependency?.(file);
79
+ for (const directory of dependencies.contexts) context.addContextDependency?.(directory);
80
+ for (const file of dependencies.missing) context.addMissingDependency?.(file);
81
+ for (const file of dependencies.build) context.addBuildDependency?.(file);
82
+ }
83
+ const module = context._module;
84
+ if (!module.buildInfo || typeof module.buildInfo !== 'object') module.buildInfo = {};
85
+ module.buildInfo[CSS_MODULE_BUILD_INFO_KEY] = {
86
+ sourceHash: prepared.sourceHash,
87
+ requests: [...prepared.requests],
88
+ consumed,
89
+ };
90
+ }
@@ -1,3 +1,9 @@
1
+ import {
2
+ clearCssModuleBuildInfo,
3
+ CSS_MODULE_BUILD_INFO_KEY,
4
+ CSS_MODULE_CONTEXT_KEY,
5
+ } from './css-module-data.js';
6
+
1
7
  /**
2
8
  * Rspack does not copy a module's layer or buildInfo into parallel-loader
3
9
  * workers. Pitch data is structured-cloned into the worker before compilation.
@@ -5,6 +11,8 @@
5
11
  export function pitch() {
6
12
  this.data ??= {};
7
13
  this.data.octaneLayer = this._module?.layer ?? null;
14
+ this.data[CSS_MODULE_CONTEXT_KEY] = this[CSS_MODULE_CONTEXT_KEY] ?? null;
15
+ clearCssModuleBuildInfo(this._module);
8
16
  if (this._module?.buildInfo && typeof this._module.buildInfo === 'object') {
9
17
  delete this._module.buildInfo.octane;
10
18
  }
@@ -19,6 +27,12 @@ export default function finalizeOctaneLoader(source, sourceMap, metadata) {
19
27
  }
20
28
  this._module.buildInfo.octane = result.buildInfo;
21
29
  }
30
+ if (result?.cssModuleBuildInfo) {
31
+ if (!this._module.buildInfo || typeof this._module.buildInfo !== 'object') {
32
+ this._module.buildInfo = {};
33
+ }
34
+ this._module.buildInfo[CSS_MODULE_BUILD_INFO_KEY] = result.cssModuleBuildInfo;
35
+ }
22
36
  for (const dependency of result?.missingDependencies ?? []) {
23
37
  this.addMissingDependency(dependency);
24
38
  }
package/src/loader.js CHANGED
@@ -2,6 +2,12 @@ import { realpathSync } from 'node:fs';
2
2
  import { dirname, isAbsolute, resolve } from 'node:path';
3
3
  import remapping from '@jridgewell/remapping';
4
4
  import { canonicalModuleId, cleanModuleId, createOctaneCompiler } from 'octane/compiler/bundler';
5
+ import {
6
+ clearCssModuleBuildInfo,
7
+ CSS_MODULE_CONTEXT_KEY,
8
+ finishCssModuleConstants,
9
+ prepareCssModuleConstants,
10
+ } from './css-module-data.js';
5
11
  import {
6
12
  inferRspackEnvironment,
7
13
  normalizeLoaderOptions,
@@ -85,6 +91,7 @@ async function resolveClientOnlyImports(context, compiler, source, id) {
85
91
  export default function octaneLoader(source, inputSourceMap) {
86
92
  this.cacheable?.(true);
87
93
  clearBuildInfo(this._module);
94
+ clearCssModuleBuildInfo(this._module);
88
95
 
89
96
  try {
90
97
  const options = normalizeLoaderOptions(this.getOptions?.() ?? {});
@@ -125,14 +132,24 @@ export default function octaneLoader(source, inputSourceMap) {
125
132
  warn: (message) => this.emitWarning?.(new Error(message)),
126
133
  });
127
134
  const id = realModuleId(this.resource ?? this.resourcePath);
135
+ const authoredSource = String(source);
136
+ const cssModuleConstants =
137
+ this[CSS_MODULE_CONTEXT_KEY]?.enabled === true
138
+ ? prepareCssModuleConstants(this, compiler, authoredSource, id, {
139
+ environment,
140
+ hmr,
141
+ dev,
142
+ })
143
+ : null;
128
144
  const finish = (clientOnlyImports, callback) => {
129
145
  try {
130
- const result = compiler.transform(String(source), id, {
146
+ const result = compiler.transform(authoredSource, id, {
131
147
  environment,
132
148
  hmr,
133
149
  dev,
134
150
  profile,
135
151
  ...(clientOnlyImports.length > 0 ? { clientOnlyImports } : null),
152
+ ...cssModuleConstants?.transformOptions,
136
153
  });
137
154
 
138
155
  if (result === null) {
@@ -141,6 +158,7 @@ export default function octaneLoader(source, inputSourceMap) {
141
158
  }
142
159
 
143
160
  registerDependencies(this, result);
161
+ finishCssModuleConstants(this, cssModuleConstants, result);
144
162
  if (result.kind === 'none') {
145
163
  callback(null, source, this.sourceMap === false ? undefined : inputSourceMap);
146
164
  return;
@@ -177,10 +195,10 @@ export default function octaneLoader(source, inputSourceMap) {
177
195
  currentReference === null &&
178
196
  typeof this.getResolve === 'function'
179
197
  ) {
180
- const requests = compiler.findServerImportRequests(String(source), id);
198
+ const requests = compiler.findServerImportRequests(authoredSource, id);
181
199
  if (requests.length > 0) {
182
200
  const asyncCallback = this.async?.() ?? callback;
183
- resolveClientOnlyImports(this, compiler, source, id).then(
201
+ resolveClientOnlyImports(this, compiler, authoredSource, id).then(
184
202
  (imports) => finish(imports, asyncCallback),
185
203
  (error) => asyncCallback(error instanceof Error ? error : new Error(String(error))),
186
204
  );
@@ -1,4 +1,5 @@
1
1
  import octaneLoader from './loader.js';
2
+ import { CSS_MODULE_BUILD_INFO_KEY, CSS_MODULE_CONTEXT_KEY } from './css-module-data.js';
2
3
 
3
4
  /**
4
5
  * Run the unchanged public Octane loader inside Rspack's worker pool. Rspack
@@ -10,6 +11,7 @@ export default function parallelOctaneLoader(source, sourceMap) {
10
11
  const module = this._module;
11
12
  const finalizer = this.loaders[this.loaderIndex - 1];
12
13
  module.layer = finalizer?.loaderItem?.data?.octaneLayer ?? undefined;
14
+ this[CSS_MODULE_CONTEXT_KEY] = finalizer?.loaderItem?.data?.[CSS_MODULE_CONTEXT_KEY] ?? undefined;
13
15
 
14
16
  const callback = this.callback.bind(this);
15
17
  const async = this.async.bind(this);
@@ -22,6 +24,9 @@ export default function parallelOctaneLoader(source, sourceMap) {
22
24
  ...(metadata ?? {}),
23
25
  __octaneParallelLoader: {
24
26
  buildInfo: module.buildInfo?.octane ?? null,
27
+ ...(module.buildInfo?.[CSS_MODULE_BUILD_INFO_KEY] === undefined
28
+ ? null
29
+ : { cssModuleBuildInfo: module.buildInfo[CSS_MODULE_BUILD_INFO_KEY] }),
25
30
  missingDependencies,
26
31
  },
27
32
  });
package/src/plugin.js CHANGED
@@ -1,13 +1,14 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { realpathSync } from 'node:fs';
2
+ import { readFileSync, realpathSync } from 'node:fs';
3
3
  import { createRequire } from 'node:module';
4
- import { isAbsolute, join, resolve } from 'node:path';
4
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import {
7
7
  CLIENT_REFERENCE_MANIFEST_FILENAME,
8
8
  createClientReferenceManifest,
9
9
  createOctaneCompiler,
10
10
  } from 'octane/compiler/bundler';
11
+ import { installCssModuleConstants } from './css-module-constants.js';
11
12
  import {
12
13
  getOctaneRspackBuildInfo,
13
14
  inferRspackEnvironment,
@@ -23,6 +24,12 @@ const parallelLoaderPath = fileURLToPath(new URL('./parallel-loader.js', import.
23
24
  const DEFAULT_MAX_WORKERS = 4;
24
25
  const OCTANE_RULE = /\.(?:tsrx|tsx|ts|js)$/i;
25
26
  const TYPESCRIPT_RULE = /\.(?:tsrx|tsx|ts)$/i;
27
+ const RUNTIME_SUBPATHS = [
28
+ 'octane/server',
29
+ 'octane/internal/client',
30
+ 'octane/internal/server',
31
+ 'octane/profiling',
32
+ ];
26
33
 
27
34
  function realRoot(path) {
28
35
  try {
@@ -40,10 +47,10 @@ function addUniqueExtensions(resolveOptions) {
40
47
  ];
41
48
  }
42
49
 
43
- function resolveRuntimeModule(request, root) {
50
+ function resolveRuntimeModule(resolver, request, root) {
44
51
  if (isAbsolute(request)) return request;
45
52
  try {
46
- return createRequire(join(root, 'package.json')).resolve(request);
53
+ return resolver.resolveSync({}, root, request);
47
54
  } catch {
48
55
  // Let Rspack produce its normal resolution diagnostic. This fallback also
49
56
  // keeps config inspection usable before peer dependencies are installed.
@@ -51,18 +58,82 @@ function resolveRuntimeModule(request, root) {
51
58
  }
52
59
  }
53
60
 
54
- function addRuntimeAlias(resolveOptions, request, root) {
55
- const aliases = resolveOptions.alias === false ? {} : (resolveOptions.alias ?? {});
56
- resolveOptions.alias = {
57
- ...aliases,
58
- octane$: resolveRuntimeModule(request, root),
59
- // Compiler-emitted metadata imports this public subpath directly. Pin it
60
- // alongside the runtime entry so raw/linked packages with their own nested
61
- // Octane copy cannot split metadata registration from runtime recording.
62
- 'octane/profiling$': resolveRuntimeModule('octane/profiling', root),
61
+ function octanePackageRoot(entry) {
62
+ if (typeof entry !== 'string' || !isAbsolute(entry)) return undefined;
63
+ let directory = dirname(entry);
64
+ while (true) {
65
+ try {
66
+ const { name } = JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8'));
67
+ if (name === 'octane') return directory;
68
+ if (name !== undefined) return undefined;
69
+ } catch {
70
+ // Entry files may be several directories below their package manifest.
71
+ }
72
+ const parent = dirname(directory);
73
+ if (parent === directory) return undefined;
74
+ directory = parent;
75
+ }
76
+ }
77
+
78
+ function mergeRuntimeAliases(aliases, additions) {
79
+ return {
80
+ ...additions,
81
+ ...(aliases === false ? {} : aliases),
82
+ octane$: additions.octane$,
63
83
  };
64
84
  }
65
85
 
86
+ function addRuntimeAliases(compiler, request, root, explicitRuntime) {
87
+ const resolveOptions = compiler.options.resolve;
88
+ // The factory is created before Rspack applies mode/target defaults. Merge
89
+ // the finalized ESM options (including `...`) before resolving; require
90
+ // conditions would select Octane's separate, non-tree-shakeable CJS graph.
91
+ const { byDependency, ...base } = resolveOptions;
92
+ const esm = compiler.webpack.util.cleverMerge(base, byDependency?.esm ?? {});
93
+ const resolver = compiler.resolverFactory.get('normal', { ...esm, dependencyType: 'esm' });
94
+ const packageResolver = compiler.resolverFactory.get('normal', {
95
+ ...esm,
96
+ alias: false,
97
+ dependencyType: 'esm',
98
+ });
99
+ const selected = resolveRuntimeModule(resolver, 'octane', root);
100
+ const override = explicitRuntime ? resolveRuntimeModule(resolver, request, root) : undefined;
101
+ const packageRoot = octanePackageRoot(override) ?? octanePackageRoot(selected) ?? root;
102
+ const aliases = esm.alias === false ? {} : (esm.alias ?? {});
103
+ const additions = {};
104
+ for (const subpath of RUNTIME_SUBPATHS) {
105
+ // Resolve package self-references without a broad `octane` alias. Linked
106
+ // packages must share the selected runtime's refs, context, and profiler.
107
+ // A consumer's explicit subpath override remains authoritative.
108
+ additions[`${subpath}$`] =
109
+ aliases[`${subpath}$`] ??
110
+ aliases[subpath] ??
111
+ resolveRuntimeModule(packageResolver, subpath, packageRoot);
112
+ }
113
+ const runtime = explicitRuntime
114
+ ? override
115
+ : request === 'octane' || selected === false
116
+ ? selected
117
+ : (additions[`${request}$`] ?? resolveRuntimeModule(resolver, request, root));
118
+ additions.octane$ = runtime;
119
+ // Exact entries precede any existing prefix alias; unrelated Octane subpaths
120
+ // (including universal renderer entries) keep their normal resolution.
121
+ resolveOptions.alias = mergeRuntimeAliases(resolveOptions.alias, additions);
122
+ if (byDependency?.esm?.alias !== undefined && byDependency.esm.alias !== false) {
123
+ // Rspack applies this map again during module resolution. A package
124
+ // selection here must not restore its client entry in a server graph.
125
+ // Preserve `false`: it explicitly disables all aliases for ESM requests.
126
+ resolveOptions.byDependency = {
127
+ ...byDependency,
128
+ esm: {
129
+ ...byDependency.esm,
130
+ alias: mergeRuntimeAliases(byDependency.esm.alias, additions),
131
+ },
132
+ };
133
+ }
134
+ return resolver;
135
+ }
136
+
66
137
  function projectRendererModule(request, root) {
67
138
  // Renderer config uses project-root IDs such as `/src/object-renderer.ts`.
68
139
  // They are never host-filesystem absolute paths, even if the same path happens
@@ -274,12 +345,20 @@ export class OctaneRspackPlugin {
274
345
  );
275
346
  const environment = this.options.environment ?? inferRspackEnvironment(compiler.options.target);
276
347
  const profile = environment === 'client' && this.options.profile === true;
277
- const hmr =
278
- environment === 'client' && hasHotModuleReplacement(compiler) && this.options.hmr !== false;
348
+ const hotModuleReplacement = hasHotModuleReplacement(compiler);
349
+ const hmr = environment === 'client' && hotModuleReplacement && this.options.hmr !== false;
279
350
  const dev =
280
351
  environment === 'client' &&
281
352
  (this.options.dev ??
282
353
  (compiler.options.mode === undefined || compiler.options.mode !== 'production'));
354
+ // Disabling Octane's own HMR wrapper does not make a hot CSS provider's
355
+ // exports immutable across replacement.
356
+ const cssModuleConstants =
357
+ this.options.cssModuleConstants !== undefined &&
358
+ this.options.cssModuleConstants !== false &&
359
+ compiler.options.mode === 'production' &&
360
+ !dev &&
361
+ !hotModuleReplacement;
283
362
  assertProfilingDefineAvailable(compiler, profile);
284
363
  saltPersistentCacheVersion(compiler, {
285
364
  root,
@@ -297,8 +376,15 @@ export class OctaneRspackPlugin {
297
376
  // transform results must not survive a requireDirective toggle.
298
377
  requireDirective: this.options.requireDirective === true,
299
378
  transpile: this.options.transpile !== false,
379
+ cssModuleConstants,
300
380
  });
301
381
  installProfilingDefine(compiler, profile);
382
+ if (cssModuleConstants) {
383
+ installCssModuleConstants(compiler, {
384
+ option: this.options.cssModuleConstants,
385
+ environment,
386
+ });
387
+ }
302
388
  const neutralCompiler = createDiscoveryCompiler(this.options, root, profile);
303
389
  const discoveryCompilers = [neutralCompiler];
304
390
  for (const specialization of Object.values(this.options.layerSpecializations ?? {})) {
@@ -309,7 +395,6 @@ export class OctaneRspackPlugin {
309
395
 
310
396
  compiler.options.resolve ??= {};
311
397
  addUniqueExtensions(compiler.options.resolve);
312
- addRuntimeAlias(compiler.options.resolve, runtimeRequest, root);
313
398
  addProjectRendererAliases(compiler.options.resolve, this.options.renderers, root);
314
399
  for (const specialization of Object.values(this.options.layerSpecializations ?? {})) {
315
400
  addProjectRendererAliases(compiler.options.resolve, specialization.renderers, root);
@@ -364,17 +449,27 @@ export class OctaneRspackPlugin {
364
449
  use: [{ loader: 'builtin:swc-loader', options: { detectSyntax: 'auto' } }],
365
450
  });
366
451
  }
452
+ const layerRuntimeAliases = [];
367
453
  for (const [layer, specialization] of Object.entries(this.options.layerSpecializations ?? {})) {
368
454
  if (specialization.runtime === undefined) continue;
455
+ const alias = { octane$: specialization.runtime };
456
+ layerRuntimeAliases.push([alias, specialization.runtime]);
369
457
  compiler.options.module.rules.push({
370
458
  issuerLayer: layer,
371
- resolve: {
372
- alias: {
373
- octane$: resolveRuntimeModule(specialization.runtime, root),
374
- },
375
- },
459
+ resolve: { alias },
376
460
  });
377
461
  }
462
+ compiler.hooks.afterResolvers.tap(PLUGIN_NAME, () => {
463
+ const resolver = addRuntimeAliases(
464
+ compiler,
465
+ runtimeRequest,
466
+ root,
467
+ this.options.runtime !== undefined,
468
+ );
469
+ for (const [alias, request] of layerRuntimeAliases) {
470
+ alias.octane$ = resolveRuntimeModule(resolver, request, root);
471
+ }
472
+ });
378
473
 
379
474
  let discovery;
380
475
  const discover = () => {
package/src/shared.js CHANGED
@@ -47,7 +47,13 @@ const LOADER_OPTION_KEYS = new Set([
47
47
  'universalRuntime',
48
48
  'layerSpecializations',
49
49
  ]);
50
- const PLUGIN_OPTION_KEYS = new Set([...LOADER_OPTION_KEYS, 'parallel', 'runtime', 'transpile']);
50
+ const PLUGIN_OPTION_KEYS = new Set([
51
+ ...LOADER_OPTION_KEYS,
52
+ 'parallel',
53
+ 'runtime',
54
+ 'transpile',
55
+ 'cssModuleConstants',
56
+ ]);
51
57
  const LAYER_SPECIALIZATION_KEYS = new Set(['runtime', 'renderers', 'universalRuntime']);
52
58
 
53
59
  function normalizeRuntimeRequest(value, label = 'runtime') {
@@ -210,6 +216,16 @@ function normalizeOptions(value, plugin) {
210
216
  throw new TypeError('@octanejs/rspack-plugin: `exclude` must be an array of path strings.');
211
217
  }
212
218
  if (plugin) assertBooleanOption(options, 'transpile');
219
+ if (
220
+ plugin &&
221
+ options.cssModuleConstants !== undefined &&
222
+ typeof options.cssModuleConstants !== 'boolean' &&
223
+ typeof options.cssModuleConstants !== 'function'
224
+ ) {
225
+ throw new TypeError(
226
+ '@octanejs/rspack-plugin: `cssModuleConstants` must be a boolean or a provider function.',
227
+ );
228
+ }
213
229
  const parallel = plugin ? normalizeParallelOption(options.parallel) : undefined;
214
230
  const renderers =
215
231
  options.renderers === undefined ? undefined : normalizeRendererConfig(options.renderers);
@@ -233,6 +249,9 @@ function normalizeOptions(value, plugin) {
233
249
  ...(plugin && parallel !== undefined ? { parallel } : null),
234
250
  ...(plugin && options.transpile !== undefined ? { transpile: options.transpile } : null),
235
251
  ...(plugin && options.runtime !== undefined ? { runtime: options.runtime } : null),
252
+ ...(plugin && options.cssModuleConstants !== undefined
253
+ ? { cssModuleConstants: options.cssModuleConstants }
254
+ : null),
236
255
  };
237
256
  if (normalized.exclude) Object.freeze(normalized.exclude);
238
257
  return Object.freeze(normalized);
package/types/index.d.ts CHANGED
@@ -1,7 +1,29 @@
1
1
  import type { Compiler, RspackPluginInstance } from '@rspack/core';
2
+ import type { OctaneCssModuleConstants } from 'octane/compiler';
3
+
4
+ export type { OctaneCssModuleConstants } from 'octane/compiler';
2
5
 
3
6
  export type OctaneRspackEnvironment = 'client' | 'server';
4
7
 
8
+ /** Exact JavaScript CSS-provider module from the current Rspack graph. */
9
+ export interface OctaneRspackCssModuleConstantModule {
10
+ /** Full module identity, including its loader chain, resource query, and layer. */
11
+ readonly id: string;
12
+ /** The resolved NormalModule resource, including its query. */
13
+ readonly resource: string;
14
+ /** Completed loader output, inspected without evaluating the module. */
15
+ readonly code: string;
16
+ /** Provider metadata copied from the module's buildInfo. */
17
+ readonly meta: Readonly<Record<string, unknown>>;
18
+ readonly environment: OctaneRspackEnvironment;
19
+ readonly layer?: string;
20
+ readonly type: string;
21
+ }
22
+
23
+ export type OctaneRspackCssModuleConstantsProvider = (
24
+ module: OctaneRspackCssModuleConstantModule,
25
+ ) => OctaneCssModuleConstants | null | undefined;
26
+
5
27
  export interface OctaneRendererRuleOptions {
6
28
  /** Glob or globs matched against canonical project-relative module IDs. */
7
29
  include: string | readonly string[];
@@ -152,6 +174,18 @@ export interface OctaneRspackLoaderOptions {
152
174
  }
153
175
 
154
176
  export interface OctaneRspackPluginOptions extends OctaneRspackLoaderOptions {
177
+ /**
178
+ * @experimental Fold proven CSS-module strings in one-shot production builds.
179
+ * `true` accepts only pure, initialized named ESM string exports. A provider
180
+ * may additionally authenticate immutable exports against the exact completed
181
+ * loader source; ordinary mutable default maps are not constants. The provider
182
+ * runs on the main thread, never in a loader worker. Malformed or stale facts
183
+ * fail the build. Native `css/module`, development, HMR, and watch builds keep
184
+ * their ordinary compilation. Eligible consumers are rebuilt once and are not
185
+ * stored in Rspack's persistent module cache. Requires the class plugin.
186
+ * @default false
187
+ */
188
+ cssModuleConstants?: boolean | OctaneRspackCssModuleConstantsProvider;
155
189
  /**
156
190
  * Compile Octane modules in Rspack worker threads. Enabled by default with
157
191
  * at most four workers; set `false` to keep compilation on the main thread.