@meteorjs/rspack 0.0.0

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 ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "@meteorjs/rspack",
3
+ "version": "0.0.0",
4
+ "description": "Configuration logic for using Rspack in Meteor projects",
5
+ "main": "index.js",
6
+ "type": "module",
7
+ "author": "",
8
+ "license": "ISC",
9
+ "dependencies": {
10
+ "@rspack/plugin-react-refresh": "^1.4.3",
11
+ "ignore-loader": "^0.1.2",
12
+ "webpack-merge": "^6.0.1"
13
+ },
14
+ "peerDependencies": {
15
+ "@rspack/cli": ">=1.3.0",
16
+ "@rspack/core": ">=1.3.0"
17
+ }
18
+ }
@@ -0,0 +1,264 @@
1
+ // RequireExternalsPlugin.js
2
+ //
3
+ // This plugin prepare the require of externals used to be lazy required by Meteor bundler.
4
+ //
5
+ // It can describe additional externals using the externals option by array, RegExp or function.
6
+ // These externals will be lazy required as well, and optionally could be resolved using
7
+ // the externalMap function if provided.
8
+ // Used for Blaze to translate require of html files to require of js files bundled by Meteor.
9
+
10
+ import fs from 'fs';
11
+ import path from 'path';
12
+
13
+ export class RequireExternalsPlugin {
14
+ constructor({
15
+ filePath,
16
+ // Externals can be:
17
+ // - An array of strings: module name must be included in the array
18
+ // - A RegExp: module name must match the regex
19
+ // - A function: function(name) must return true for the module name
20
+ externals = null,
21
+ // ExternalMap is a function that receives the request object and returns the external request path
22
+ // It can be used to customize how external modules are mapped to file paths
23
+ // If not provided, the default behavior is to map the external module name.
24
+ externalMap = null,
25
+ } = {}) {
26
+ this.pluginName = 'RequireExternalsPlugin';
27
+
28
+ // Prepare externals
29
+ this._externals = externals;
30
+ this._externalMap = externalMap;
31
+ this._defaultExternalPrefix = 'external ';
32
+
33
+ // Prepare paths
34
+ this.filePath = path.resolve(process.cwd(), filePath);
35
+ this.backRoot = '../'.repeat(
36
+ filePath.replace(/^\.?\/+/, '').split('/').length - 1
37
+ );
38
+
39
+ // Initialize funcCount based on existing helpers in the file
40
+ this._funcCount = this._computeNextFuncCount();
41
+ }
42
+
43
+ // Helper method to check if a module name matches the externals or default prefix
44
+ _isExternalModule(name) {
45
+ if (typeof name !== 'string') return false;
46
+
47
+ // Check externals if provided
48
+ if (this._externals) {
49
+ // If externals is an array, use includes method
50
+ if (Array.isArray(this._externals)) {
51
+ if (this._externals.includes(name)) {
52
+ return { isExternal: true, type: 'externals', value: name };
53
+ }
54
+ }
55
+ // If externals is a RegExp, use test method
56
+ else if (this._externals instanceof RegExp) {
57
+ if (this._externals.test(name)) {
58
+ return { isExternal: true, type: 'externals', value: name };
59
+ }
60
+ }
61
+ // If externals is a function, call it with the name
62
+ else if (typeof this._externals === 'function') {
63
+ if (this._externals(name)) {
64
+ return { isExternal: true, type: 'externals', value: name };
65
+ }
66
+ }
67
+ }
68
+
69
+ if (name.startsWith(this._defaultExternalPrefix)) {
70
+ return { isExternal: true, type: 'prefix', value: name };
71
+ }
72
+
73
+ return { isExternal: false };
74
+ }
75
+
76
+ // Helper method to extract package name from module name
77
+ _extractPackageName(name) {
78
+ let pkg = name.slice(this._defaultExternalPrefix.length);
79
+ if (pkg.startsWith('"') && pkg.endsWith('"')) pkg = pkg.slice(1, -1);
80
+
81
+ // If the extracted package name is a path, use the path as is
82
+ if (
83
+ pkg &&
84
+ (path.isAbsolute(pkg) || pkg.startsWith('./') || pkg.startsWith('../'))
85
+ ) {
86
+ const module = this.externalsMeta.get(pkg);
87
+ if (module) {
88
+ return `${this.backRoot}${module.relativeRequest}`;
89
+ }
90
+ return `${this.backRoot}${name}`;
91
+ }
92
+
93
+ return pkg;
94
+ }
95
+
96
+ apply(compiler) {
97
+ // Initialize externalsMeta if it doesn't exist
98
+ this.externalsMeta = this.externalsMeta || new Map();
99
+
100
+ // Only set compiler.options.externals if both externals and externalMap are defined
101
+ if (this._externals && this._externalMap) {
102
+ compiler.options.externals = [
103
+ ...compiler.options.externals || [],
104
+ (module, callback) => {
105
+ const { request, context } = module;
106
+ const matchInfo = this._isExternalModule(request);
107
+ if (matchInfo.isExternal) {
108
+
109
+ let externalRequest;
110
+ // Use externalMap function if provided
111
+ if (this._externalMap && typeof this._externalMap === 'function') {
112
+ externalRequest = this._externalMap(module);
113
+
114
+ const relContext = path.relative(process.cwd(), context);
115
+ // Store the original request to resolve properly the lazy html require later
116
+ this.externalsMeta.set(externalRequest, {
117
+ originalRequest: request,
118
+ externalRequest,
119
+ relativeRequest: path.join(relContext, request),
120
+ });
121
+
122
+ // tell Rspack "don't bundle this, import it at runtime"
123
+ return callback(null, externalRequest);
124
+ }
125
+ }
126
+
127
+ callback(); // otherwise normal resolution
128
+ }
129
+ ];
130
+ }
131
+
132
+ compiler.hooks.done.tap({ name: this.pluginName, stage: -10 }, (stats) => {
133
+ // 1) Ensure globalThis.module / exports block is present
134
+ this._ensureGlobalThisModule();
135
+
136
+ // 2) Re-load existing requires from disk on every run
137
+ const existing = this._readExistingRequires();
138
+
139
+ // 2a) Compute the *current* externals in this build
140
+ const info = stats.toJson({ modules: true });
141
+ const current = new Set();
142
+ for (const m of info.modules) {
143
+ const matchInfo = this._isExternalModule(m.name);
144
+ if (matchInfo.isExternal) {
145
+ const pkg = this._extractPackageName(m.name, matchInfo);
146
+ if (pkg) {
147
+ current.add(pkg);
148
+ }
149
+ }
150
+ }
151
+
152
+ // 2b) Remove any requires that are no longer in `current`
153
+ const toRemove = [...existing].filter(p => !current.has(p));
154
+ if (toRemove.length) {
155
+ let content = fs.readFileSync(this.filePath, 'utf-8');
156
+
157
+ // Strip stale require(...) lines
158
+ for (const pkg of toRemove) {
159
+ const re = new RegExp(`^.*require\\('${pkg}'\\);?.*(\\r?\\n)?`, 'gm');
160
+ content = content.replace(re, '');
161
+ }
162
+
163
+ // Strip out any now-empty helper functions:
164
+ // function lazyExternalImportsX() {
165
+ // }
166
+ const emptyFnRe = /^function\s+lazyExternalImports\d+\s*\(\)\s*{\s*}\s*(\r?\n)?/gm;
167
+ content = content.replace(emptyFnRe, '');
168
+
169
+ // Write the cleaned file back
170
+ fs.writeFileSync(this.filePath, content, 'utf-8');
171
+
172
+ // Re-populate `existing` so the add-diff is accurate
173
+ existing.clear();
174
+ for (const match of content.matchAll(/require\('([^']+)'\)/g)) {
175
+ existing.add(match[1]);
176
+ }
177
+ }
178
+
179
+ // 3) Collect any new externals from this build
180
+ const newRequires = [];
181
+ for (const module of info.modules) {
182
+ const name = module.name;
183
+ const matchInfo = this._isExternalModule(name);
184
+ if (!matchInfo.isExternal) continue;
185
+
186
+ const pkg = this._extractPackageName(name, matchInfo);
187
+ if (pkg && !existing.has(pkg)) {
188
+ existing.add(pkg);
189
+ newRequires.push(`require('${pkg}')`);
190
+ }
191
+ }
192
+
193
+ // 4) Append new imports if any
194
+ if (newRequires.length) {
195
+ const fnName = `lazyExternalImports${this._funcCount++}`;
196
+ const body = newRequires.map(req => ` ${req};`).join('\n');
197
+ const fnCode = `\nfunction ${fnName}() {\n${body}\n}\n`;
198
+ try {
199
+ fs.appendFileSync(this.filePath, fnCode);
200
+ } catch (err) {
201
+ console.error(`Failed to append imports to ${this.filePath}:`, err);
202
+ }
203
+ }
204
+ });
205
+ }
206
+
207
+ _computeNextFuncCount() {
208
+ let max = 0;
209
+ if (fs.existsSync(this.filePath)) {
210
+ try {
211
+ const content = fs.readFileSync(this.filePath, 'utf-8');
212
+ const fnRe = /function\s+lazyExternalImports(\d+)\s*\(\)/g;
213
+ let match;
214
+ while ((match = fnRe.exec(content)) !== null) {
215
+ const n = parseInt(match[1], 10);
216
+ if (n > max) max = n;
217
+ }
218
+ } catch {
219
+ // ignore read errors
220
+ }
221
+ }
222
+ // next count is max found plus one
223
+ return max + 1;
224
+ }
225
+
226
+ _ensureGlobalThisModule() {
227
+ const block = [
228
+ `/* Polyfill globalThis.module & exports */`,
229
+ `if (typeof globalThis.module === 'undefined') {`,
230
+ ` globalThis.module = { exports: {} };`,
231
+ `}`,
232
+ `if (typeof globalThis.exports === 'undefined') {`,
233
+ ` globalThis.exports = globalThis.module.exports;`,
234
+ `}`
235
+ ].join('\n') + '\n';
236
+
237
+ let content = '';
238
+ if (fs.existsSync(this.filePath)) {
239
+ content = fs.readFileSync(this.filePath, 'utf-8');
240
+ if (!content.includes(`typeof globalThis.module === 'undefined'`)) {
241
+ // Prepend so it lives at the very top
242
+ fs.writeFileSync(this.filePath, content + '\n' + block, 'utf-8');
243
+ }
244
+ } else {
245
+ // File doesn’t exist yet: create with just the block
246
+ fs.writeFileSync(this.filePath, block, 'utf-8');
247
+ }
248
+ }
249
+
250
+ _readExistingRequires() {
251
+ const existing = new Set();
252
+ try {
253
+ const content = fs.readFileSync(this.filePath, 'utf-8');
254
+ const requireRegex = /require\('([^']+)'\)/g;
255
+ let match;
256
+ while ((match = requireRegex.exec(content)) !== null) {
257
+ existing.add(match[1]);
258
+ }
259
+ } catch {
260
+ // ignore if file missing or unreadable
261
+ }
262
+ return existing;
263
+ }
264
+ }
@@ -0,0 +1,286 @@
1
+ import { DefinePlugin, BannerPlugin } from '@rspack/core';
2
+ import fs from 'fs';
3
+ import { createRequire } from 'module';
4
+ import path from 'path';
5
+ import { merge } from 'webpack-merge';
6
+
7
+ import { RequireExternalsPlugin } from './plugins/RequireExtenalsPlugin.js';
8
+
9
+ const require = createRequire(import.meta.url);
10
+
11
+ // Safe require that doesn't throw if the module isn't found
12
+ function safeRequire(moduleName) {
13
+ try {
14
+ return require(moduleName);
15
+ } catch (error) {
16
+ if (
17
+ error.code === 'MODULE_NOT_FOUND' &&
18
+ error.message.includes(moduleName)
19
+ ) {
20
+ return null;
21
+ }
22
+ throw error; // rethrow if it's a different error
23
+ }
24
+ }
25
+
26
+ // Persistent filesystem cache strategy
27
+ function createCacheStrategy(mode) {
28
+ return {
29
+ cache: true,
30
+ experiments: {
31
+ cache: {
32
+ version: `swc-${mode}`,
33
+ type: 'persistent',
34
+ storage: {
35
+ type: 'filesystem',
36
+ directory: 'node_modules/.cache/rspack',
37
+ },
38
+ },
39
+ },
40
+ };
41
+ }
42
+
43
+ // SWC loader rule (JSX/JS)
44
+ function createSwcConfig({ isRun }) {
45
+ return {
46
+ test: /\.[jt]sx?$/,
47
+ exclude: /node_modules|\.meteor\/local/,
48
+ loader: 'builtin:swc-loader',
49
+ options: {
50
+ jsc: {
51
+ baseUrl: process.cwd(),
52
+ paths: { '/*': ['*'] },
53
+ parser: { syntax: 'ecmascript', jsx: true },
54
+ target: 'es2015',
55
+ transform: {
56
+ react: {
57
+ development: isRun,
58
+ refresh: isRun,
59
+ },
60
+ },
61
+ },
62
+ },
63
+ };
64
+ }
65
+
66
+ // Watch options shared across both builds
67
+ const watchOptions = {
68
+ ignored: ['**/main.html', '**/dist/**', '**/.meteor/local/**'],
69
+ };
70
+
71
+ /**
72
+ * @param {{ isClient: boolean; isServer: boolean; isDevelopment?: boolean; isProduction?: boolean; isTest?: boolean }} Meteor
73
+ * @param {{ mode?: string; clientEntry?: string; serverEntry?: string; clientOutputFolder?: string; serverOutputFolder?: string; bundlesContext?: string; assetsContext?: string; serverAssetsContext?: string }} argv
74
+ * @returns {import('@rspack/cli').Configuration[]}
75
+ */
76
+ export default function (inMeteor = {}, argv = {}) {
77
+ // Transform Meteor env properties to proper boolean values
78
+ const Meteor = { ...inMeteor };
79
+ // Convert string boolean values to actual booleans
80
+ for (const key in Meteor) {
81
+ if (Meteor[key] === 'true' || Meteor[key] === true) {
82
+ Meteor[key] = true;
83
+ } else if (Meteor[key] === 'false' || Meteor[key] === false) {
84
+ Meteor[key] = false;
85
+ }
86
+ }
87
+
88
+ const isProd = Meteor.isProduction || argv.mode === 'production';
89
+ const isDev = Meteor.isDevelopment || !isProd;
90
+ const isTest = Meteor.isTest;
91
+ const isClient = Meteor.isClient;
92
+ const isRun = Meteor.isRun;
93
+ const isReactEnabled = Meteor.isReactEnabled;
94
+ const mode = isProd ? 'production' : 'development';
95
+
96
+ // Determine entry points
97
+ const entryPath = Meteor.entryPath;
98
+
99
+ // Determine output points
100
+ const outputPath = Meteor.outputPath;
101
+ const outputFilename = Meteor.outputFilename;
102
+
103
+ // Determine run point
104
+ const runPath = Meteor.runPath;
105
+
106
+ // Determine banner
107
+ const bannerOutput = JSON.parse(Meteor.bannerOutput || '');
108
+
109
+ // Determine output directories
110
+ const clientOutputDir = path.resolve(process.cwd(), 'public');
111
+ const serverOutputDir = path.resolve(process.cwd(), 'server');
112
+
113
+ // Determine context for bundles and assets
114
+ const buildContext = Meteor.buildContext || '_rspack';
115
+ const bundlesContext = Meteor.bundlesContext || 'bundles';
116
+ const assetsContext = Meteor.assetsContext || 'assets';
117
+
118
+ if (Meteor.isDebug) {
119
+ console.log('[i] Rspack mode:', mode);
120
+ console.log('[i] Meteor flags:', Meteor);
121
+ }
122
+
123
+ // Base client config
124
+ let clientConfig = {
125
+ name: 'meteor-client',
126
+ target: 'web',
127
+ mode,
128
+ entry: path.resolve(process.cwd(), buildContext, entryPath),
129
+ output: {
130
+ path: clientOutputDir,
131
+ filename: () =>
132
+ isDev ? outputFilename : `../${buildContext}/${outputPath}`,
133
+ libraryTarget: 'commonjs',
134
+ publicPath: '/',
135
+ chunkFilename: `${bundlesContext}/[id].[chunkhash].js`,
136
+ assetModuleFilename: `${assetsContext}/[hash][ext][query]`,
137
+ },
138
+ optimization: {
139
+ usedExports: true,
140
+ splitChunks: { chunks: 'async' },
141
+ },
142
+ module: {
143
+ rules: [
144
+ createSwcConfig({ isRun }),
145
+ ...(Meteor.isBlazeEnabled
146
+ ? [
147
+ {
148
+ test: /\.html$/,
149
+ loader: 'ignore-loader',
150
+ },
151
+ ]
152
+ : []),
153
+ ],
154
+ },
155
+ resolve: { extensions: ['.js', '.jsx', '.json'] },
156
+ externals: [/^(meteor.*|react$|react-dom$)/],
157
+ plugins: [
158
+ ...(isRun
159
+ ? [
160
+ ...(isReactEnabled
161
+ ? [new (safeRequire('@rspack/plugin-react-refresh'))()]
162
+ : []),
163
+ new RequireExternalsPlugin({
164
+ filePath: path.join(buildContext, runPath),
165
+ ...(Meteor.isBlazeEnabled && {
166
+ externals: /\.html$/,
167
+ externalMap: (module) => {
168
+ const { request, context } = module;
169
+ if (request.endsWith('.html')) {
170
+ const relContext = path.relative(process.cwd(), context);
171
+ const { name } = path.parse(request);
172
+ return `./${relContext}/template.${name}.js`;
173
+ }
174
+ return request;
175
+ },
176
+ }),
177
+ }),
178
+ ].filter(Boolean)
179
+ : []),
180
+ new DefinePlugin({
181
+ 'Meteor.isClient': JSON.stringify(true),
182
+ 'Meteor.isServer': JSON.stringify(false),
183
+ 'Meteor.isTest': JSON.stringify(isTest),
184
+ 'Meteor.isDevelopment': JSON.stringify(isDev),
185
+ 'Meteor.isProduction': JSON.stringify(isProd),
186
+ }),
187
+ new BannerPlugin({
188
+ banner: bannerOutput,
189
+ entryOnly: true,
190
+ }),
191
+ ],
192
+ watchOptions,
193
+ devtool: isDev ? 'source-map' : 'hidden-source-map',
194
+ ...(isRun && {
195
+ devServer: {
196
+ static: { directory: clientOutputDir, publicPath: '/__rspack__/' },
197
+ hot: true,
198
+ liveReload: true,
199
+ ...(Meteor.isBlazeEnabled && { hot: false }),
200
+ port: 3005,
201
+ devMiddleware: {
202
+ writeToDisk: false,
203
+ },
204
+ },
205
+ experiments: { incremental: true },
206
+ }),
207
+ };
208
+
209
+ // Base server config
210
+ let serverConfig = {
211
+ name: 'meteor-server',
212
+ target: 'node',
213
+ mode,
214
+ entry: path.resolve(process.cwd(), buildContext, entryPath),
215
+ output: {
216
+ path: serverOutputDir,
217
+ filename: () => `../${buildContext}/${outputPath}`,
218
+ libraryTarget: 'commonjs',
219
+ chunkFilename: `${bundlesContext}/[id].[chunkhash].js`,
220
+ assetModuleFilename: `${assetsContext}/[hash][ext][query]`,
221
+ },
222
+ optimization: { usedExports: true },
223
+ module: {
224
+ rules: [
225
+ {
226
+ test: /\.meteor\/local/,
227
+ use: 'builtin:empty-loader',
228
+ sideEffects: false,
229
+ },
230
+ createSwcConfig({ isRun }),
231
+ ],
232
+ },
233
+ resolve: {
234
+ extensions: ['.js', '.jsx', '.json'],
235
+ modules: ['node_modules', path.resolve(process.cwd())],
236
+ conditionNames: ['import', 'require', 'node', 'default'],
237
+ },
238
+ externals: [/^(meteor.*|react|react-dom)/],
239
+ plugins: [
240
+ new DefinePlugin({
241
+ 'Meteor.isClient': JSON.stringify(false),
242
+ 'Meteor.isServer': JSON.stringify(true),
243
+ 'Meteor.isTest': JSON.stringify(isTest),
244
+ 'Meteor.isDevelopment': JSON.stringify(isDev),
245
+ 'Meteor.isProduction': JSON.stringify(isProd),
246
+ }),
247
+ new BannerPlugin({
248
+ banner: bannerOutput,
249
+ entryOnly: true,
250
+ }),
251
+ ],
252
+ watchOptions,
253
+ devtool: isRun ? 'source-map' : 'hidden-source-map',
254
+ ...(isRun &&
255
+ merge(createCacheStrategy(mode), { experiments: { incremental: true } })),
256
+ };
257
+
258
+ // Load and apply project-level overrides for the selected build
259
+ const projectConfigPath = path.resolve(process.cwd(), 'rspack.config.js');
260
+
261
+ // Check if we're in a Meteor package directory by looking at the path
262
+ const isMeteorPackageConfig = process.cwd().includes('/packages/rspack');
263
+ if (fs.existsSync(projectConfigPath) && !isMeteorPackageConfig) {
264
+ const projectConfig =
265
+ require(projectConfigPath)?.default || require(projectConfigPath);
266
+
267
+ const userConfig =
268
+ typeof projectConfig === 'function'
269
+ ? projectConfig(Meteor, argv)
270
+ : projectConfig;
271
+
272
+ if (Meteor.isClient) {
273
+ clientConfig = merge(clientConfig, userConfig);
274
+ }
275
+ if (Meteor.isServer) {
276
+ serverConfig = merge(serverConfig, userConfig);
277
+ }
278
+ }
279
+
280
+ // Return the appropriate configuration
281
+ if (isClient) {
282
+ return [clientConfig];
283
+ }
284
+ // Meteor.isServer
285
+ return [serverConfig];
286
+ }