meocord 3.2.2 → 4.0.0-beta.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/CHANGELOG.md +60 -0
- package/README.md +160 -28
- package/THIRD_PARTY_NOTICES.md +40 -0
- package/dist/cjs/_shared/{controller.decorator-eT0oHb8D.cjs → controller.decorator-9nIxOp9y.cjs} +1 -1
- package/dist/cjs/_shared/{metadata-key.enum-DITxfZlw.cjs → metadata-key.enum-aABGdodb.cjs} +0 -4
- package/dist/cjs/_shared/{theme-EmG1ZEwV.cjs → theme-BXdSoifk.cjs} +4 -15
- package/dist/cjs/common/index.cjs +1 -5
- package/dist/cjs/core/index.cjs +67 -5
- package/dist/cjs/decorator/index.cjs +2 -2
- package/dist/cjs/enum/index.cjs +1 -1
- package/dist/cjs/testing/index.cjs +2 -6
- package/dist/esm/bin/app-template/meocord.config.ts.template +8 -13
- package/dist/esm/bin/app-template/package.json.template +12 -12
- package/dist/esm/bin/app-template/vitest.config.ts.template +6 -2
- package/dist/esm/bin/builder-template/builder/context-menu.builder.template +2 -2
- package/dist/esm/bin/builder-template/builder/primary-entry-point.builder.template +2 -2
- package/dist/esm/bin/builder-template/builder/slash.builder.template +2 -2
- package/dist/esm/bin/builder-template/controller/autocomplete.controller.template +4 -3
- package/dist/esm/bin/builder-template/controller/context-menu.controller.template +2 -2
- package/dist/esm/bin/builder-template/controller/primary-entry-point.controller.template +2 -2
- package/dist/esm/bin/builder-template/controller/slash.controller.template +2 -2
- package/dist/esm/bin/helper/controller-generator.helper.js +43 -26
- package/dist/esm/bin/helper/guard-generator.helper.js +8 -2
- package/dist/esm/bin/helper/service-generator.helper.js +8 -2
- package/dist/esm/bin/meocord.js +160 -140
- package/dist/esm/build/native-addons.js +235 -0
- package/dist/esm/build/rsbuild-config.js +169 -0
- package/dist/esm/common/decorator.js +0 -4
- package/dist/esm/common/theme.js +1 -5
- package/dist/esm/core/meocord-factory.js +4 -0
- package/dist/esm/enum/controller.enum.js +1 -5
- package/dist/esm/enum/metadata-key.enum.js +0 -4
- package/dist/esm/package.json.js +1 -1
- package/dist/esm/testing/mock-fn.js +1 -5
- package/dist/esm/util/generator-cli.util.js +54 -10
- package/dist/esm/util/json.util.js +0 -4
- package/dist/esm/util/meocord-config-loader.util.js +14 -2
- package/dist/esm/util/platform.util.js +66 -0
- package/dist/esm/util/runtime.util.js +20 -7
- package/dist/esm/util/tsconfig.util.js +1 -1
- package/dist/esm/util/wait.util.js +1 -5
- package/dist/types/common/index.d.ts +0 -16
- package/dist/types/core/index.d.ts +0 -12
- package/dist/types/decorator/index.d.ts +1 -31
- package/dist/types/enum/index.d.ts +0 -5
- package/dist/types/interface/index.d.ts +46 -57
- package/dist/types/testing/index.d.ts +4 -19
- package/meocord.eslint.cjs +0 -6
- package/meocord.eslint.d.ts +0 -6
- package/meocord.eslint.mjs +0 -6
- package/package.json +19 -23
- package/webpack.config.js +0 -147
package/dist/esm/bin/meocord.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import path from 'path';
|
|
3
|
-
import
|
|
3
|
+
import { createRsbuild } from '@rsbuild/core';
|
|
4
4
|
import { Logger } from '../common/logger.js';
|
|
5
5
|
import '../common/theme.js';
|
|
6
6
|
import { spawn } from 'node:child_process';
|
|
@@ -10,7 +10,6 @@ import { GeneratorCLI } from './generator.js';
|
|
|
10
10
|
import { runtimePrefixFor, AppGeneratorHelper } from './helper/app-generator.helper.js';
|
|
11
11
|
import * as fs from 'node:fs';
|
|
12
12
|
import { setEnvironment, compileAndValidateConfig, validateDiscordToken } from '../util/common.util.js';
|
|
13
|
-
import { prepareModifiedTsConfig } from '../util/tsconfig.util.js';
|
|
14
13
|
import { Command } from 'commander';
|
|
15
14
|
import { simpleGit } from 'simple-git';
|
|
16
15
|
import { execSync } from 'child_process';
|
|
@@ -21,8 +20,10 @@ import { resolveOwnVersion } from '../util/package-version.util.js';
|
|
|
21
20
|
import { buildAppCommand, resolveRuntime } from '../util/runtime.util.js';
|
|
22
21
|
import packageJson from '../package.json.js';
|
|
23
22
|
import { fileURLToPath } from 'url';
|
|
24
|
-
import
|
|
25
|
-
import
|
|
23
|
+
import { assertNoWebpackHook, createRsbuildConfig, DISCORD_OPTIONAL_NATIVES } from '../build/rsbuild-config.js';
|
|
24
|
+
import { createNativeExternals, nativeCarrier, copyPackagesInto, bundledModuleFiles, assertNoBundledNativeAddons, findBundledNativeAddons } from '../build/native-addons.js';
|
|
25
|
+
import { writePlatformManifest, PLATFORM_MANIFEST } from '../util/platform.util.js';
|
|
26
|
+
import { loadMeoCordSourceConfig } from '../util/meocord-config-loader.util.js';
|
|
26
27
|
|
|
27
28
|
const __filename$1 = fileURLToPath(import.meta.url);
|
|
28
29
|
const __dirname$1 = path.dirname(__filename$1);
|
|
@@ -216,6 +217,91 @@ copies or substantial portions of the Software.
|
|
|
216
217
|
p.outro(`MeoCord app "${kebabCaseAppName}" is ready!`);
|
|
217
218
|
}
|
|
218
219
|
/**
|
|
220
|
+
* Builds the bundler configuration for this project and creates an Rsbuild instance.
|
|
221
|
+
*
|
|
222
|
+
* The base configuration comes from {@link createRsbuildConfig}; the application's
|
|
223
|
+
* `rsbuild` hook, if it declares one, is given the chance to modify it.
|
|
224
|
+
*/ async createBundler(mode) {
|
|
225
|
+
// Read from source on every build: the compiled copy in dist is the previous build's.
|
|
226
|
+
const meocordConfig = loadMeoCordSourceConfig();
|
|
227
|
+
assertNoWebpackHook(meocordConfig);
|
|
228
|
+
const base = createRsbuildConfig({
|
|
229
|
+
mode,
|
|
230
|
+
bundleDependencies: meocordConfig?.bundleDependencies,
|
|
231
|
+
externals: meocordConfig?.externals
|
|
232
|
+
});
|
|
233
|
+
const config = meocordConfig?.rsbuild?.(base) ?? base;
|
|
234
|
+
// A native addon cannot be inlined into JavaScript, so when bundling, each one is kept out of
|
|
235
|
+
// the bundle as the bundler meets it and recorded for build() to copy into dist. Added after
|
|
236
|
+
// the application's hook, so a hook that sets externals of its own does not drop it.
|
|
237
|
+
const natives = meocordConfig?.bundleDependencies ? createNativeExternals(this.projectRoot) : undefined;
|
|
238
|
+
if (natives) {
|
|
239
|
+
var _config;
|
|
240
|
+
(_config = config).output ?? (_config.output = {});
|
|
241
|
+
const existing = config.output.externals;
|
|
242
|
+
config.output.externals = [
|
|
243
|
+
...Array.isArray(existing) ? existing : existing ? [
|
|
244
|
+
existing
|
|
245
|
+
] : [],
|
|
246
|
+
natives.externals
|
|
247
|
+
];
|
|
248
|
+
}
|
|
249
|
+
const rsbuild = await createRsbuild({
|
|
250
|
+
cwd: this.projectRoot,
|
|
251
|
+
config
|
|
252
|
+
});
|
|
253
|
+
return {
|
|
254
|
+
rsbuild,
|
|
255
|
+
meocordConfig,
|
|
256
|
+
natives
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Makes a bundled build's dist runnable on its own: copies every package the bundle still
|
|
261
|
+
* imports -- the native addons found while building, anything listed in `externals`, and
|
|
262
|
+
* discord.js's optional accelerators if they are installed -- into `dist/node_modules`, and
|
|
263
|
+
* marks dist as ESM. Deploying is then copying dist, with no install step.
|
|
264
|
+
*/ packDependencies(meocordConfig, natives) {
|
|
265
|
+
const dist = path.join(this.projectRoot, 'dist');
|
|
266
|
+
const packages = new Map([
|
|
267
|
+
...natives
|
|
268
|
+
].map(([name, { dir }])=>[
|
|
269
|
+
name,
|
|
270
|
+
dir
|
|
271
|
+
]));
|
|
272
|
+
const nativeNames = new Set(natives.keys());
|
|
273
|
+
const listed = (meocordConfig.externals ?? []).filter((item)=>typeof item === 'string');
|
|
274
|
+
for (const name of [
|
|
275
|
+
...listed,
|
|
276
|
+
...DISCORD_OPTIONAL_NATIVES
|
|
277
|
+
]){
|
|
278
|
+
const dir = path.join(this.projectRoot, 'node_modules', name);
|
|
279
|
+
if (packages.has(name) || !fs.existsSync(path.join(dir, 'package.json'))) continue;
|
|
280
|
+
packages.set(name, dir);
|
|
281
|
+
if (nativeCarrier(dir, name, this.projectRoot)) nativeNames.add(name);
|
|
282
|
+
}
|
|
283
|
+
// Replaced rather than merged, so a package dropped from the application does not linger.
|
|
284
|
+
fs.rmSync(path.join(dist, 'node_modules'), {
|
|
285
|
+
recursive: true,
|
|
286
|
+
force: true
|
|
287
|
+
});
|
|
288
|
+
const copied = copyPackagesInto(packages, this.projectRoot, dist);
|
|
289
|
+
fs.writeFileSync(path.join(dist, 'package.json'), `${JSON.stringify({
|
|
290
|
+
type: 'module'
|
|
291
|
+
}, null, 2)}\n`);
|
|
292
|
+
if (nativeNames.size > 0) {
|
|
293
|
+
writePlatformManifest(dist);
|
|
294
|
+
this.logger.info(`Native addons packed into dist: ${[
|
|
295
|
+
...nativeNames
|
|
296
|
+
].join(', ')}`);
|
|
297
|
+
} else {
|
|
298
|
+
fs.rmSync(path.join(dist, PLATFORM_MANIFEST), {
|
|
299
|
+
force: true
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
if (copied.length > 0) this.logger.info(`dist/node_modules holds ${copied.length} packages; nothing else to install.`);
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
219
305
|
* Builds the MeoCord application in the specified mode.
|
|
220
306
|
*
|
|
221
307
|
* @param mode - The build mode ('production' or 'development').
|
|
@@ -223,45 +309,21 @@ copies or substantial portions of the Software.
|
|
|
223
309
|
try {
|
|
224
310
|
this.clearConsole();
|
|
225
311
|
this.logger.info(`Building ${mode} version...`);
|
|
226
|
-
const
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
if (!compiler) {
|
|
232
|
-
this.logger.error('Failed to create webpack compiler instance.');
|
|
233
|
-
throw new Error('Failed to create webpack compiler instance.');
|
|
234
|
-
}
|
|
235
|
-
// Workaround for Bun: Keep the event loop alive while webpack runs
|
|
236
|
-
// Bun sometimes exits before async callbacks fire
|
|
237
|
-
let keepAliveTimer = null;
|
|
238
|
-
await new Promise((resolve, reject)=>{
|
|
239
|
-
keepAliveTimer = setInterval(()=>{
|
|
240
|
-
// Keeps event loop active
|
|
241
|
-
}, 100);
|
|
242
|
-
compiler.run((err, stats)=>{
|
|
243
|
-
if (keepAliveTimer) {
|
|
244
|
-
clearInterval(keepAliveTimer);
|
|
245
|
-
keepAliveTimer = null;
|
|
246
|
-
}
|
|
247
|
-
if (err) {
|
|
248
|
-
this.logger.error(`Build encountered an error: ${err.message}`);
|
|
249
|
-
return reject(`Build encountered an error: ${err.message}`);
|
|
250
|
-
}
|
|
251
|
-
if (stats?.hasErrors()) {
|
|
252
|
-
this.logger.error('Build failed due to errors in the compilation process:', stats.compilation.errors);
|
|
253
|
-
} else {
|
|
254
|
-
this.logger.info(`${capitalize(mode)} build completed successfully.`);
|
|
255
|
-
}
|
|
256
|
-
compiler.close((closeErr)=>{
|
|
257
|
-
if (closeErr) {
|
|
258
|
-
this.logger.error(`Error occurred while closing the compiler: ${closeErr.message}`);
|
|
259
|
-
return reject(`Error occurred while closing the compiler: ${closeErr.message}`);
|
|
260
|
-
}
|
|
261
|
-
resolve();
|
|
262
|
-
});
|
|
312
|
+
const { rsbuild, meocordConfig, natives } = await this.createBundler(mode);
|
|
313
|
+
const bundledFiles = [];
|
|
314
|
+
if (meocordConfig?.bundleDependencies) {
|
|
315
|
+
rsbuild.onAfterBuild(({ stats })=>{
|
|
316
|
+
bundledFiles.push(...bundledModuleFiles(stats));
|
|
263
317
|
});
|
|
264
|
-
}
|
|
318
|
+
}
|
|
319
|
+
await rsbuild.build();
|
|
320
|
+
if (meocordConfig?.bundleDependencies && natives) {
|
|
321
|
+
// A safety net behind the externals function: a native addon that still reached the
|
|
322
|
+
// bundle would run on this machine -- through its node_modules -- and fail in production.
|
|
323
|
+
assertNoBundledNativeAddons(findBundledNativeAddons(bundledFiles, this.projectRoot));
|
|
324
|
+
this.packDependencies(meocordConfig, natives.found);
|
|
325
|
+
}
|
|
326
|
+
this.logger.info(`${capitalize(mode)} build completed successfully.`);
|
|
265
327
|
} catch (error) {
|
|
266
328
|
this.logger.error(`Build process failed: ${error.message}`);
|
|
267
329
|
await wait(100);
|
|
@@ -275,82 +337,49 @@ copies or substantial portions of the Software.
|
|
|
275
337
|
const configPath = path.resolve(this.projectRoot, 'meocord.config.ts');
|
|
276
338
|
if (!fs.existsSync(configPath)) return;
|
|
277
339
|
try {
|
|
278
|
-
|
|
279
|
-
|
|
340
|
+
// Deliberately built without the application's `rsbuild` hook: this compiles the very
|
|
341
|
+
// file that declares that hook, so applying it here would let a config shape its own
|
|
342
|
+
// compilation. It does follow `bundleDependencies`, though: the config imports packages
|
|
343
|
+
// too (dotenv, usually), and a bot bundled to run without node_modules would otherwise
|
|
344
|
+
// fail on its own config file.
|
|
345
|
+
const meocordConfig = loadMeoCordSourceConfig();
|
|
346
|
+
const bundleDependencies = meocordConfig?.bundleDependencies ?? false;
|
|
347
|
+
const base = createRsbuildConfig({
|
|
348
|
+
mode: 'development',
|
|
280
349
|
entry: configPath,
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
{
|
|
291
|
-
|
|
292
|
-
use: {
|
|
293
|
-
loader: 'swc-loader',
|
|
294
|
-
options: {
|
|
295
|
-
jsc: {
|
|
296
|
-
parser: {
|
|
297
|
-
syntax: 'typescript',
|
|
298
|
-
tsx: false,
|
|
299
|
-
decorators: true
|
|
300
|
-
},
|
|
301
|
-
transform: {
|
|
302
|
-
decoratorMetadata: true,
|
|
303
|
-
legacyDecorator: true
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
},
|
|
308
|
-
exclude: /node_modules/
|
|
350
|
+
bundleDependencies,
|
|
351
|
+
externals: meocordConfig?.externals
|
|
352
|
+
});
|
|
353
|
+
const rsbuild = await createRsbuild({
|
|
354
|
+
cwd: this.projectRoot,
|
|
355
|
+
config: {
|
|
356
|
+
...base,
|
|
357
|
+
source: {
|
|
358
|
+
...base.source,
|
|
359
|
+
entry: {
|
|
360
|
+
'meocord.config': configPath
|
|
309
361
|
}
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
library: {
|
|
327
|
-
type: 'module'
|
|
362
|
+
},
|
|
363
|
+
output: {
|
|
364
|
+
...base.output,
|
|
365
|
+
distPath: {
|
|
366
|
+
root: path.resolve(this.projectRoot, 'dist'),
|
|
367
|
+
js: ''
|
|
368
|
+
},
|
|
369
|
+
filename: {
|
|
370
|
+
js: '[name].mjs'
|
|
371
|
+
},
|
|
372
|
+
minify: {
|
|
373
|
+
js: false
|
|
374
|
+
},
|
|
375
|
+
sourceMap: false,
|
|
376
|
+
// Runs beside the application build in the same dist; cleaning would delete it.
|
|
377
|
+
cleanDistPath: false
|
|
328
378
|
}
|
|
329
|
-
},
|
|
330
|
-
experiments: {
|
|
331
|
-
outputModule: true
|
|
332
|
-
},
|
|
333
|
-
optimization: {
|
|
334
|
-
minimize: false
|
|
335
379
|
}
|
|
336
380
|
});
|
|
337
|
-
await
|
|
338
|
-
|
|
339
|
-
if (err || stats?.hasErrors()) {
|
|
340
|
-
this.logger.warn('Failed to compile meocord.config.ts — runtime will fall back to source config.');
|
|
341
|
-
resolve();
|
|
342
|
-
return;
|
|
343
|
-
}
|
|
344
|
-
compiler.close((closeErr)=>{
|
|
345
|
-
if (closeErr) {
|
|
346
|
-
resolve();
|
|
347
|
-
return;
|
|
348
|
-
}
|
|
349
|
-
this.logger.info('Config compiled to dist/meocord.config.mjs');
|
|
350
|
-
resolve();
|
|
351
|
-
});
|
|
352
|
-
});
|
|
353
|
-
});
|
|
381
|
+
await rsbuild.build();
|
|
382
|
+
this.logger.info('Config compiled to dist/meocord.config.mjs');
|
|
354
383
|
} catch {
|
|
355
384
|
this.logger.warn('Failed to compile meocord.config.ts — runtime will fall back to source config.');
|
|
356
385
|
}
|
|
@@ -396,30 +425,22 @@ copies or substantial portions of the Software.
|
|
|
396
425
|
this.clearConsole();
|
|
397
426
|
this.logger.log('Starting watch mode...');
|
|
398
427
|
await this.compileConfig();
|
|
399
|
-
const webpackConfig = (await import(this.webpackConfigPath)).default;
|
|
400
|
-
const compiler = webpack({
|
|
401
|
-
...webpackConfig,
|
|
402
|
-
mode: 'development'
|
|
403
|
-
});
|
|
404
|
-
if (!compiler) {
|
|
405
|
-
this.logger.error('Failed to create webpack compiler instance.');
|
|
406
|
-
await wait(100);
|
|
407
|
-
process.exit(1);
|
|
408
|
-
}
|
|
409
428
|
let isRunning = false;
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
429
|
+
let watching;
|
|
430
|
+
const watch = async ()=>{
|
|
431
|
+
const { rsbuild } = await this.createBundler('development');
|
|
432
|
+
// Runs after every rebuild, which is where the application is restarted. A failed
|
|
433
|
+
// rebuild reports its own errors and does not reach here, so the process already
|
|
434
|
+
// running is left alone rather than being replaced by a broken build.
|
|
435
|
+
rsbuild.onAfterBuild(()=>{
|
|
436
|
+
this.restartApp();
|
|
437
|
+
isRunning = true;
|
|
438
|
+
});
|
|
439
|
+
watching = await rsbuild.build({
|
|
440
|
+
watch: true
|
|
421
441
|
});
|
|
422
|
-
|
|
442
|
+
};
|
|
443
|
+
await watch();
|
|
423
444
|
let debounceWatcher;
|
|
424
445
|
const fsWatcher = fs.watch(path.resolve(process.cwd(), 'meocord.config.ts'), ()=>{
|
|
425
446
|
clearTimeout(debounceWatcher);
|
|
@@ -431,8 +452,8 @@ copies or substantial portions of the Software.
|
|
|
431
452
|
this.appProcess.kill();
|
|
432
453
|
this.appProcess = null;
|
|
433
454
|
}
|
|
434
|
-
await
|
|
435
|
-
watch();
|
|
455
|
+
await watching?.close();
|
|
456
|
+
await watch();
|
|
436
457
|
}
|
|
437
458
|
}, 300);
|
|
438
459
|
});
|
|
@@ -449,11 +470,11 @@ copies or substantial portions of the Software.
|
|
|
449
470
|
fsWatcher.close();
|
|
450
471
|
if (this.appProcess && !this.appProcess.killed) {
|
|
451
472
|
this.appProcess.on('exit', async ()=>{
|
|
452
|
-
await
|
|
473
|
+
await watching?.close();
|
|
453
474
|
process.exit(0);
|
|
454
475
|
});
|
|
455
476
|
} else {
|
|
456
|
-
await
|
|
477
|
+
await watching?.close();
|
|
457
478
|
process.exit(0);
|
|
458
479
|
}
|
|
459
480
|
});
|
|
@@ -518,7 +539,6 @@ copies or substantial portions of the Software.
|
|
|
518
539
|
this.logger = new Logger(this.appName);
|
|
519
540
|
this.projectRoot = process.cwd();
|
|
520
541
|
this.mainJSPath = path.join(this.projectRoot, 'dist', 'main.js');
|
|
521
|
-
this.webpackConfigPath = path.resolve(__dirname$1, '..', '..', '..', 'webpack.config.js');
|
|
522
542
|
this.generatorCLI = new GeneratorCLI(this.appName);
|
|
523
543
|
this.appGeneratorHelper = new AppGeneratorHelper();
|
|
524
544
|
this.version = resolveOwnVersion(__dirname$1, packageJson.version);
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, existsSync, cpSync } from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The package a resolved module file belongs to, and the directory it was installed in.
|
|
6
|
+
*
|
|
7
|
+
* Takes the innermost `node_modules` segment, so a nested copy is attributed to itself rather
|
|
8
|
+
* than to the package it is nested under. Undefined for application code.
|
|
9
|
+
*/ function packageFromPath(file) {
|
|
10
|
+
// A module identifier can carry loaders before the resource (`loader!/path/to/file`) and a
|
|
11
|
+
// query after it; only the path itself says where the package lives.
|
|
12
|
+
const resource = file.slice(file.lastIndexOf('!') + 1).split('?')[0];
|
|
13
|
+
const segments = resource.split(/[\\/]/);
|
|
14
|
+
const index = segments.lastIndexOf('node_modules');
|
|
15
|
+
if (index === -1 || index + 1 >= segments.length) return undefined;
|
|
16
|
+
const scoped = segments[index + 1].startsWith('@');
|
|
17
|
+
if (scoped && index + 2 >= segments.length) return undefined;
|
|
18
|
+
const end = index + (scoped ? 3 : 2);
|
|
19
|
+
return {
|
|
20
|
+
name: segments.slice(index + 1, end).join('/'),
|
|
21
|
+
dir: segments.slice(0, end).join(path.sep)
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Every source file a build pulled into its output.
|
|
26
|
+
*
|
|
27
|
+
* Concatenated modules nest their members, and a multi-environment build nests whole
|
|
28
|
+
* compilations as children, so both are walked.
|
|
29
|
+
*/ function bundledModuleFiles(stats) {
|
|
30
|
+
if (!stats) return [];
|
|
31
|
+
const json = stats.toJson({
|
|
32
|
+
all: false,
|
|
33
|
+
modules: true,
|
|
34
|
+
nestedModules: true,
|
|
35
|
+
// A production build concatenates most of node_modules into a few modules, and the members
|
|
36
|
+
// of a concatenation are orphans -- hidden from stats unless asked for. Without this every
|
|
37
|
+
// concatenated package, sharp included, was invisible here.
|
|
38
|
+
orphanModules: true,
|
|
39
|
+
modulesSpace: Infinity,
|
|
40
|
+
nestedModulesSpace: Infinity
|
|
41
|
+
});
|
|
42
|
+
const collect = (modules)=>(modules ?? []).flatMap((module)=>[
|
|
43
|
+
module.nameForCondition ?? module.identifier ?? '',
|
|
44
|
+
...collect(module.modules),
|
|
45
|
+
// Stats may group modules by path, nesting them under `children`.
|
|
46
|
+
...collect(module.children)
|
|
47
|
+
]);
|
|
48
|
+
const compilations = json.children?.length ? json.children : [
|
|
49
|
+
json
|
|
50
|
+
];
|
|
51
|
+
return compilations.flatMap((compilation)=>collect(compilation.modules)).filter(Boolean);
|
|
52
|
+
}
|
|
53
|
+
/** Whether a directory holds a compiled addon, not counting packages nested inside it. */ function containsNativeBinary(dir, depth = 0) {
|
|
54
|
+
if (depth > 5) return false;
|
|
55
|
+
let entries;
|
|
56
|
+
try {
|
|
57
|
+
entries = readdirSync(dir, {
|
|
58
|
+
withFileTypes: true
|
|
59
|
+
});
|
|
60
|
+
} catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
return entries.some((entry)=>{
|
|
64
|
+
if (entry.isFile()) return entry.name.endsWith('.node');
|
|
65
|
+
if (!entry.isDirectory() || entry.name === 'node_modules') return false;
|
|
66
|
+
return containsNativeBinary(path.join(dir, entry.name), depth + 1);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Where an optional dependency of the package in `from` was installed, if it was.
|
|
71
|
+
*
|
|
72
|
+
* Nested under the package, beside it (pnpm's layout, and npm's hoisting within a scope), or
|
|
73
|
+
* hoisted to the project root. A platform package that did not match this machine is not
|
|
74
|
+
* installed at all, which is how only the right binary is found.
|
|
75
|
+
*/ function resolveDependencyDir(name, from, root) {
|
|
76
|
+
const candidates = [
|
|
77
|
+
path.join(from, 'node_modules', name),
|
|
78
|
+
path.join(path.dirname(from), name),
|
|
79
|
+
path.join(path.dirname(path.dirname(from)), name),
|
|
80
|
+
path.join(root, 'node_modules', name)
|
|
81
|
+
];
|
|
82
|
+
return candidates.find((dir)=>existsSync(path.join(dir, 'package.json')));
|
|
83
|
+
}
|
|
84
|
+
/** A package's declared dependencies, runtime and optional, or none if it cannot be read. */ function readDependencies(dir) {
|
|
85
|
+
try {
|
|
86
|
+
const pkg = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8'));
|
|
87
|
+
return {
|
|
88
|
+
dependencies: Object.keys(pkg.dependencies ?? {}),
|
|
89
|
+
optional: Object.keys(pkg.optionalDependencies ?? {})
|
|
90
|
+
};
|
|
91
|
+
} catch {
|
|
92
|
+
return {
|
|
93
|
+
dependencies: [],
|
|
94
|
+
optional: []
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The package holding the compiled addon an installed package loads, or undefined for plain
|
|
100
|
+
* JavaScript.
|
|
101
|
+
*
|
|
102
|
+
* The binary is rarely in the package that gets imported. node-gyp packages keep it in their own
|
|
103
|
+
* directory, under `build/Release`. napi-rs packages and sharp ship a JavaScript loader and publish
|
|
104
|
+
* each platform's binary as a separate package, declared as an optional dependency so only the
|
|
105
|
+
* matching one installs. Both layouts are checked.
|
|
106
|
+
*/ function nativeCarrier(dir, name, root) {
|
|
107
|
+
if (containsNativeBinary(dir)) return name;
|
|
108
|
+
return readDependencies(dir).optional.find((dependency)=>{
|
|
109
|
+
const dependencyDir = resolveDependencyDir(dependency, dir, root);
|
|
110
|
+
return dependencyDir !== undefined && containsNativeBinary(dependencyDir);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
/** Bundled packages that load a native addon, mapped to the package holding the binary. */ function findBundledNativeAddons(bundledFiles, root) {
|
|
114
|
+
const packages = new Map();
|
|
115
|
+
for (const file of bundledFiles){
|
|
116
|
+
const found = packageFromPath(file);
|
|
117
|
+
if (found && !packages.has(found.dir)) packages.set(found.dir, found.name);
|
|
118
|
+
}
|
|
119
|
+
const natives = new Map();
|
|
120
|
+
for (const [dir, name] of packages){
|
|
121
|
+
if (natives.has(name)) continue;
|
|
122
|
+
const carrier = nativeCarrier(dir, name, root);
|
|
123
|
+
if (carrier) natives.set(name, carrier);
|
|
124
|
+
}
|
|
125
|
+
return natives;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* The package a bare import request names, or undefined for a relative path, an absolute path, or
|
|
129
|
+
* a Node builtin -- none of which live in node_modules.
|
|
130
|
+
*/ function packageNameOfRequest(request) {
|
|
131
|
+
if (!request || request.startsWith('.') || request.startsWith('/') || /^[a-z]+:/i.test(request)) return undefined;
|
|
132
|
+
if (/^[A-Za-z]:[\\/]/.test(request)) return undefined;
|
|
133
|
+
const [first, second] = request.split('/');
|
|
134
|
+
if (!first.startsWith('@')) return first;
|
|
135
|
+
return second ? `${first}/${second}` : undefined;
|
|
136
|
+
}
|
|
137
|
+
/** Where `name` resolves from `context` the way Node would: the nearest node_modules walking up. */ function resolveInstalledPackage(name, context, root) {
|
|
138
|
+
let dir = context;
|
|
139
|
+
for(;;){
|
|
140
|
+
const candidate = path.join(dir, 'node_modules', name);
|
|
141
|
+
if (existsSync(path.join(candidate, 'package.json'))) return candidate;
|
|
142
|
+
const parent = path.dirname(dir);
|
|
143
|
+
if (parent === dir) break;
|
|
144
|
+
dir = parent;
|
|
145
|
+
}
|
|
146
|
+
const hoisted = path.join(root, 'node_modules', name);
|
|
147
|
+
return existsSync(path.join(hoisted, 'package.json')) ? hoisted : undefined;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* An Rspack `externals` function that keeps every native addon out of the bundle.
|
|
151
|
+
*
|
|
152
|
+
* Decided while the bundler resolves imports, so it sees every request -- including one made by a
|
|
153
|
+
* plain JavaScript dependency deep in the graph -- and never has to be told what to look for. Each
|
|
154
|
+
* native package it keeps out is recorded in `found`, so the build can copy it into the output.
|
|
155
|
+
*/ function createNativeExternals(root) {
|
|
156
|
+
const found = new Map();
|
|
157
|
+
const plain = new Set();
|
|
158
|
+
const externals = ({ request, context }, callback)=>{
|
|
159
|
+
const name = request ? packageNameOfRequest(request) : undefined;
|
|
160
|
+
if (!name || plain.has(name)) return callback();
|
|
161
|
+
if (found.has(name)) return callback(undefined, request);
|
|
162
|
+
const dir = resolveInstalledPackage(name, context ?? root, root);
|
|
163
|
+
const carrier = dir ? nativeCarrier(dir, name, root) : undefined;
|
|
164
|
+
if (!dir || !carrier) {
|
|
165
|
+
plain.add(name);
|
|
166
|
+
return callback();
|
|
167
|
+
}
|
|
168
|
+
found.set(name, {
|
|
169
|
+
dir,
|
|
170
|
+
carrier
|
|
171
|
+
});
|
|
172
|
+
callback(undefined, request);
|
|
173
|
+
};
|
|
174
|
+
return {
|
|
175
|
+
externals,
|
|
176
|
+
found
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Copies packages, with everything they need at runtime, into `<outDir>/node_modules`.
|
|
181
|
+
*
|
|
182
|
+
* This is what lets a bundled application run with nothing installed beside it: the packages that
|
|
183
|
+
* could not be bundled travel inside the output directory, where Node finds them by walking up
|
|
184
|
+
* from the bundle. Each package's installed dependencies and optional dependencies are followed --
|
|
185
|
+
* that is where a platform binary lives -- and one not installed for this platform is skipped.
|
|
186
|
+
* Type-only `@types` packages are left behind; some packages list them as runtime dependencies,
|
|
187
|
+
* and they only add weight.
|
|
188
|
+
*
|
|
189
|
+
* @returns The names copied.
|
|
190
|
+
*/ function copyPackagesInto(packages, root, outDir) {
|
|
191
|
+
const copied = new Set();
|
|
192
|
+
const copy = (name, dir)=>{
|
|
193
|
+
if (copied.has(name) || name.startsWith('@types/')) return;
|
|
194
|
+
copied.add(name);
|
|
195
|
+
const target = path.join(outDir, 'node_modules', name);
|
|
196
|
+
// A package's own nested node_modules comes along with it, so only hoisted dependencies
|
|
197
|
+
// need finding separately.
|
|
198
|
+
cpSync(dir, target, {
|
|
199
|
+
recursive: true,
|
|
200
|
+
dereference: true
|
|
201
|
+
});
|
|
202
|
+
const { dependencies, optional } = readDependencies(dir);
|
|
203
|
+
for (const dependency of [
|
|
204
|
+
...dependencies,
|
|
205
|
+
...optional
|
|
206
|
+
]){
|
|
207
|
+
if (existsSync(path.join(dir, 'node_modules', dependency, 'package.json'))) continue;
|
|
208
|
+
const dependencyDir = resolveDependencyDir(dependency, dir, root);
|
|
209
|
+
if (dependencyDir) copy(dependency, dependencyDir);
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
for (const [name, dir] of packages)copy(name, dir);
|
|
213
|
+
return [
|
|
214
|
+
...copied
|
|
215
|
+
];
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Refuses a bundled build that swallowed a native addon.
|
|
219
|
+
*
|
|
220
|
+
* Such a build does not fail on its own. It succeeds, the bot starts, and on the build machine it
|
|
221
|
+
* even works, because the bundle resolves the addon back into that machine's node_modules by
|
|
222
|
+
* absolute path. Anywhere else it fails the first time the addon loads -- often a command, long
|
|
223
|
+
* after startup. Failing the build is the only point this can be caught.
|
|
224
|
+
*/ function assertNoBundledNativeAddons(found) {
|
|
225
|
+
if (found.size === 0) return;
|
|
226
|
+
const lines = [
|
|
227
|
+
...found
|
|
228
|
+
].map(([bundled, carrier])=>carrier === bundled ? ` - ${bundled}` : ` - ${bundled} (its binary ships in ${carrier})`);
|
|
229
|
+
const names = [
|
|
230
|
+
...found.keys()
|
|
231
|
+
].map((name)=>`'${name}'`).join(', ');
|
|
232
|
+
throw new Error(`bundleDependencies bundled ${found.size === 1 ? 'a package that loads' : 'packages that load'} a native addon:\n` + `${lines.join('\n')}\n` + 'A native addon is a compiled binary for one platform and cannot travel inside the bundle. The build ' + 'would run on this machine and fail in production the first time the addon loads. Keep ' + `${found.size === 1 ? 'it' : 'them'} out of the bundle and install ${found.size === 1 ? 'it' : 'them'} ` + `on the server:\n\n externals: [${names}]`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export { assertNoBundledNativeAddons, bundledModuleFiles, copyPackagesInto, createNativeExternals, findBundledNativeAddons, nativeCarrier, packageFromPath, packageNameOfRequest };
|