@meteorjs/rspack 3.0.0-rc.0 → 3.0.0-rc.2

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/lib/clean.js ADDED
@@ -0,0 +1,47 @@
1
+ const path = require('path');
2
+ const fs = require('fs');
3
+
4
+ function isOutside(relative) {
5
+ return relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative);
6
+ }
7
+
8
+ // output.path can be an app's public/ or private/ source directory. Only the
9
+ // configured generated contexts belong to this compiler's cleanup.
10
+ function keepOutsideContexts(outputPath, contexts) {
11
+ const outputRoot = path.resolve(outputPath);
12
+ const owned = contexts
13
+ .filter(context => typeof context === 'string' && context.length > 0)
14
+ .map(context => path.relative(outputRoot, path.resolve(outputRoot, context)))
15
+ .filter(context => context && !isOutside(context));
16
+
17
+ return filename => {
18
+ if (typeof filename !== 'string' || filename.includes('\0')) return true;
19
+ const relative = path.relative(outputRoot, path.resolve(outputRoot, filename));
20
+ if (isOutside(relative)) return true;
21
+
22
+ const canClean = owned.some(context =>
23
+ relative === context || relative.startsWith(`${context}${path.sep}`) ||
24
+ // Rspack calls keep on directories before walking their children. Let
25
+ // it traverse the output root and parents of nested owned contexts.
26
+ relative === '' || context.startsWith(`${relative}${path.sep}`)
27
+ );
28
+ if (!canClean) return true;
29
+
30
+ // Rspack follows directory symlinks while cleaning. Check every component,
31
+ // including the output root, even when it calls keep directly on a file.
32
+ let current = outputRoot;
33
+ const segments = relative ? relative.split(path.sep) : [];
34
+ try {
35
+ for (const segment of ['', ...segments]) {
36
+ current = path.join(current, segment);
37
+ if (fs.lstatSync(current).isSymbolicLink()) return true;
38
+ }
39
+ } catch {
40
+ // If ownership cannot be checked, preserve the path.
41
+ return true;
42
+ }
43
+ return false;
44
+ };
45
+ }
46
+
47
+ module.exports = { keepOutsideContexts };
@@ -0,0 +1,125 @@
1
+ const path = require('path');
2
+ const os = require('os');
3
+ const fs = require('fs');
4
+ const { keepOutsideContexts } = require('./clean');
5
+
6
+ describe('Rspack output cleanup ownership', () => {
7
+ let root;
8
+ let outputPath;
9
+
10
+ function writeFile(filename, content = 'generated') {
11
+ fs.mkdirSync(path.dirname(filename), { recursive: true });
12
+ fs.writeFileSync(filename, content);
13
+ }
14
+
15
+ function linkDirectory(target, link) {
16
+ fs.mkdirSync(path.dirname(link), { recursive: true });
17
+ fs.symlinkSync(target, link, process.platform === 'win32' ? 'junction' : 'dir');
18
+ }
19
+
20
+ beforeEach(() => {
21
+ root = fs.mkdtempSync(path.join(os.tmpdir(), 'meteor-clean-'));
22
+ outputPath = path.join(root, 'build', 'build-agent', 'app', 'private');
23
+ fs.mkdirSync(outputPath, { recursive: true });
24
+ });
25
+
26
+ afterEach(() => {
27
+ fs.rmSync(root, { recursive: true, force: true });
28
+ });
29
+
30
+ test.each([
31
+ ['build-assets', 'build-chunks'],
32
+ ['build-assets-test', 'build-chunks-test'],
33
+ ['build-assets-local-secondary-app-test', 'build-chunks-local-secondary-app-test'],
34
+ ['generated/assets', 'generated/chunks'],
35
+ ])('cleans only %s and %s, with absolute or relative callbacks', (assets, chunks) => {
36
+ writeFile(path.join(outputPath, assets, 'stale.svg'));
37
+ writeFile(path.join(outputPath, chunks, 'stale.js'));
38
+ const keep = keepOutsideContexts(outputPath, [assets, chunks]);
39
+ const cases = [
40
+ ['', false],
41
+ [assets, false],
42
+ [`${assets}/stale.svg`, false],
43
+ [`${chunks}/stale.js`, false],
44
+ ['ordinary.txt', true],
45
+ ['build', true],
46
+ ['build-notes/guide.txt', true],
47
+ ['docs/build/guide.txt', true],
48
+ [`${assets}-another-mode/keep.svg`, true],
49
+ ['build-assets-local-unrelated/keep.svg', true],
50
+ ['unrelated/build-chunks/stale.js', true],
51
+ ['../private-neighbor/build-assets/keep.txt', true],
52
+ ];
53
+ for (const [file, expected] of cases) {
54
+ for (const input of [file, path.resolve(outputPath, file)]) {
55
+ expect({ input, keep: keep(input) }).toEqual({ input, keep: expected });
56
+ }
57
+ }
58
+ });
59
+
60
+ test('traverses a shared parent without cleaning a sibling context', () => {
61
+ writeFile(path.join(outputPath, 'build-chunks/web.browser/stale.js'));
62
+ const keep = keepOutsideContexts(outputPath, [
63
+ 'build-chunks/web.browser/assets', 'build-chunks/web.browser',
64
+ ]);
65
+ expect(keep(outputPath)).toBe(false);
66
+ expect(keep(path.join(outputPath, 'build-chunks'))).toBe(false);
67
+ expect(keep('build-chunks/web.browser/stale.js')).toBe(false);
68
+ expect(keep('build-chunks/web.browser.legacy/keep.js')).toBe(true);
69
+ expect(keep('build-chunks/web.cordova/keep.js')).toBe(true);
70
+ });
71
+
72
+ test('keeps files when no safe child context is configured', () => {
73
+ const keep = keepOutsideContexts(outputPath, ['', '.', '..', '../public', null]);
74
+ for (const input of [outputPath, 'ordinary.txt', '../public/keep.txt', null, 'bad\0path']) {
75
+ expect({ input, keep: keep(input) }).toEqual({ input, keep: true });
76
+ }
77
+ });
78
+
79
+ test.each([
80
+ ['context root', 'build-assets', 'build-assets', false, 'keep.txt'],
81
+ ['nested link', 'build-assets/linked', 'build-assets', true, 'keep.txt'],
82
+ ['custom context parent', 'generated', 'generated/assets', false, 'assets/keep.txt'],
83
+ ])('keeps a symlink at the %s and paths below it', (_name, linkPath, context, targetInsideOutput, child) => {
84
+ const target = path.join(targetInsideOutput ? outputPath : root, 'source-assets');
85
+ writeFile(path.join(target, child), 'source asset');
86
+ linkDirectory(target, path.join(outputPath, linkPath));
87
+ writeFile(path.join(outputPath, 'build-chunks/stale.js'));
88
+ const keep = keepOutsideContexts(outputPath, [context, 'build-chunks']);
89
+
90
+ for (const file of [linkPath, `${linkPath}/${child}`]) {
91
+ expect(keep(file)).toBe(true);
92
+ expect(keep(path.join(outputPath, file))).toBe(true);
93
+ }
94
+ expect(keep('build-chunks/stale.js')).toBe(false);
95
+ });
96
+
97
+ test('keeps a symlinked output root and its generated descendants', () => {
98
+ const target = path.join(root, 'shared-public');
99
+ writeFile(path.join(target, 'build-assets/keep.txt'), 'shared asset');
100
+ fs.rmdirSync(outputPath);
101
+ linkDirectory(target, outputPath);
102
+ const keep = keepOutsideContexts(outputPath, ['build-assets']);
103
+
104
+ expect(keep(outputPath)).toBe(true);
105
+ expect(keep('build-assets/keep.txt')).toBe(true);
106
+ });
107
+
108
+ test('preserves an uninspectable path without disabling cleanup elsewhere', () => {
109
+ const denied = path.join(outputPath, 'build-assets');
110
+ writeFile(path.join(denied, 'keep.txt'));
111
+ writeFile(path.join(outputPath, 'build-chunks/stale.js'));
112
+ const lstat = fs.lstatSync;
113
+ const spy = jest.spyOn(fs, 'lstatSync').mockImplementation((filename, ...args) => {
114
+ if (filename === denied) throw Object.assign(new Error('permission denied'), { code: 'EACCES' });
115
+ return lstat(filename, ...args);
116
+ });
117
+ try {
118
+ const keep = keepOutsideContexts(outputPath, ['build-assets', 'build-chunks']);
119
+ expect(keep('build-assets/keep.txt')).toBe(true);
120
+ expect(keep('build-chunks/stale.js')).toBe(false);
121
+ } finally {
122
+ spy.mockRestore();
123
+ }
124
+ });
125
+ });
@@ -0,0 +1,33 @@
1
+ // Type checkers and other plugins/watchers can invalidate a compilation without
2
+ // changing its output. This can affect any Rspack project, regardless of language.
3
+ // Rspack already compares asset contents before writing them, so track emitted
4
+ // flags and asset names without copying and hashing entire bundles in JS.
5
+ function createOutputTracker() {
6
+ let version = 0;
7
+ let previousNames;
8
+
9
+ return stats => {
10
+ const { assets } = stats.toJson({
11
+ all: false,
12
+ assets: true,
13
+ cachedAssets: true,
14
+ relatedAssets: true,
15
+ });
16
+ const pending = [...assets];
17
+ const names = [];
18
+ let emitted = false;
19
+ for (let i = 0; i < pending.length; i++) {
20
+ const asset = pending[i];
21
+ names.push(asset.name);
22
+ emitted ||= asset.emitted;
23
+ // Source maps are nested under their parent asset in Rspack stats.
24
+ if (asset.related) pending.push(...asset.related);
25
+ }
26
+ const currentNames = JSON.stringify(names.sort());
27
+ if (emitted || currentNames !== previousNames) version++;
28
+ previousNames = currentNames;
29
+ return version;
30
+ };
31
+ }
32
+
33
+ module.exports = { createOutputTracker };
@@ -0,0 +1,62 @@
1
+ const { createOutputTracker } = require('./compilationOutput.js');
2
+
3
+ const stats = (assets, { hash = 'compilation-hash', hasErrors = false } = {}) => ({
4
+ hash,
5
+ hasErrors: () => hasErrors,
6
+ toJson: () => ({ assets }),
7
+ });
8
+ const asset = (name, emitted = false, related = []) => ({
9
+ name, emitted, related,
10
+ });
11
+
12
+ describe('server output tracking', () => {
13
+ test('ignores compilation-only changes and asset ordering', () => {
14
+ const track = createOutputTracker();
15
+ const first = track(stats([
16
+ asset('server.js', true),
17
+ asset('chunk.js', true),
18
+ ]));
19
+
20
+ expect(track(stats([
21
+ asset('chunk.js'),
22
+ asset('server.js'),
23
+ ], { hash: 'different-compilation-hash' }))).toBe(first);
24
+
25
+ expect(track(stats([
26
+ asset('server.js'),
27
+ asset('chunk.js', true),
28
+ ]))).not.toBe(first);
29
+ });
30
+
31
+ test('detects source-map-only changes and asset removals', () => {
32
+ const track = createOutputTracker();
33
+ const first = track(stats([
34
+ asset('server.js', false, [asset('server.js.map')]),
35
+ asset('chunk.js'),
36
+ ]));
37
+ const updatedMap = track(stats([
38
+ asset('server.js', false, [asset('server.js.map', true)]),
39
+ asset('chunk.js'),
40
+ ]));
41
+ expect(updatedMap).not.toBe(first);
42
+
43
+ const removedChunk = track(stats([
44
+ asset('server.js', false, [asset('server.js.map')]),
45
+ ]));
46
+ expect(removedChunk).not.toBe(updatedMap);
47
+ expect(track(stats([
48
+ asset('server.js', false, [asset('server.js.map')]),
49
+ ]))).toBe(removedChunk);
50
+ });
51
+
52
+ test('keeps output changes visible when a failed build recovers without writing again', () => {
53
+ const track = createOutputTracker();
54
+ const lastSuccessful = track(stats([asset('server.js', true)]));
55
+ const failed = track(stats([asset('server.js', true)], { hasErrors: true }));
56
+ // Meteor ignores failed compilations; recovery must still differ from the
57
+ // last successful version even when Rspack has already written the output.
58
+ const recovered = track(stats([asset('server.js')]));
59
+ expect(recovered).toBe(failed);
60
+ expect(recovered).not.toBe(lastSuccessful);
61
+ });
62
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meteorjs/rspack",
3
- "version": "3.0.0-rc.0",
3
+ "version": "3.0.0-rc.2",
4
4
  "description": "Configuration logic for using Rspack in Meteor projects",
5
5
  "main": "index.js",
6
6
  "type": "commonjs",
package/rspack.config.js CHANGED
@@ -30,6 +30,8 @@ const { loadUserAndOverrideConfig } = require('./lib/meteorRspackConfigHelpers.j
30
30
  const { prepareMeteorRspackConfig } = require("./lib/meteorRspackConfigFactory");
31
31
  const { extractLocalDependencies } = require('./lib/localDependenciesHelpers.js');
32
32
  const { createTestClientNodePolyfillConfig } = require("./lib/testClientNodePolyfills.js");
33
+ const { createOutputTracker } = require('./lib/compilationOutput.js');
34
+ const { keepOutsideContexts } = require('./lib/clean.js');
33
35
 
34
36
  // Safe require that doesn't throw if the module isn't found
35
37
  function safeRequire(moduleName) {
@@ -206,16 +208,6 @@ function createRemoteDevServerConfig() {
206
208
  return {};
207
209
  }
208
210
 
209
- // Keep files outside of build folders
210
- function keepOutsideBuild() {
211
- return (p) => {
212
- const normalized = '/' + path.normalize(p).replaceAll(path.sep, '/').replace(/^\/+/, '');
213
- const isInBuildRoot = /\/build(\/|$)/.test(normalized);
214
- const isInBuildStar = /\/build-[^/]+(\/|$)/.test(normalized);
215
- return !(isInBuildRoot || isInBuildStar);
216
- };
217
- }
218
-
219
211
  /**
220
212
  * @param {{ isClient: boolean; isServer: boolean; isDevelopment?: boolean; isProduction?: boolean; isTest?: boolean }} Meteor
221
213
  * @param {{ mode?: string; clientEntry?: string; serverEntry?: string; clientOutputFolder?: string; serverOutputFolder?: string; chunksContext?: string; assetsContext?: string; serverAssetsContext?: string }} argv
@@ -643,7 +635,9 @@ module.exports = async function (inMeteor = {}, argv = {}) {
643
635
  cssChunkFilename: `${clientChunkPrefix}[id]${
644
636
  isProd ? ".[contenthash]" : ""
645
637
  }.css`,
646
- ...(isProd && { clean: clientOutputContext ? true : { keep: keepOutsideBuild() } }),
638
+ ...(isProd && { clean: clientOutputContext ? true : {
639
+ keep: keepOutsideContexts(clientOutputDir, [assetsContext, chunksContext]),
640
+ } }),
647
641
  },
648
642
  optimization: {
649
643
  usedExports: true,
@@ -766,7 +760,9 @@ module.exports = async function (inMeteor = {}, argv = {}) {
766
760
  library: { type: "commonjs2" },
767
761
  chunkFilename: `${chunksContext}/[id]${isProd ? ".[chunkhash]" : ""}.js`,
768
762
  assetModuleFilename,
769
- ...(isProd && { clean: { keep: keepOutsideBuild() } }),
763
+ ...(isProd && { clean: {
764
+ keep: keepOutsideContexts(serverOutputDir, [assetsContext, chunksContext]),
765
+ } }),
770
766
  },
771
767
  optimization: {
772
768
  usedExports: true,
@@ -961,6 +957,7 @@ module.exports = async function (inMeteor = {}, argv = {}) {
961
957
  }
962
958
 
963
959
  // Add MeteorRspackOutputPlugin as the last plugin to output compilation info
960
+ const trackServerOutput = createOutputTracker();
964
961
  const meteorRspackOutputPlugin = new MeteorRspackOutputPlugin({
965
962
  getData: (stats, { isRebuild, compilationCount, compiler }) => {
966
963
  const generatedAppPaths = [
@@ -988,7 +985,13 @@ module.exports = async function (inMeteor = {}, argv = {}) {
988
985
  hasErrors: stats.hasErrors(),
989
986
  hasWarnings: stats.hasWarnings(),
990
987
  hasAppSourceChanges,
991
- hash: stats.hash,
988
+ // Use output changes to decide development server restarts. Production,
989
+ // test, native, and client compilations keep Rspack's original hash.
990
+ // Include failed emissions in the version so recovery still restarts
991
+ // if the output changed before the errors were cleared.
992
+ hash: isServer && isDevEnvironment
993
+ ? trackServerOutput(stats)
994
+ : stats.hash,
992
995
  timestamp: Date.now(),
993
996
  statsOverrided,
994
997
  compilationCount,