feat: improve test & bundler (#14434)

#### PR Dependency Tree


* **PR #14434** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Introduced rspack bundler as an alternative to webpack for optimized
builds.

* **Tests & Quality**
* Added comprehensive editor semantic tests covering markdown, hotkeys,
and slash-menu operations.
* Expanded CI cross-browser testing to Chromium, Firefox, and WebKit;
improved shape-rendering tests to account for zoom.

* **Bug Fixes**
  * Corrected CSS overlay styling for development servers.
  * Fixed TypeScript typings for build tooling.

* **Other**
  * Document duplication now produces consistent "(n)" suffixes.
  * French i18n completeness increased to 100%.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-02-14 16:09:09 +08:00
committed by GitHub
parent 3bc28ba78c
commit 2b71b3f345
25 changed files with 1913 additions and 333 deletions
+77
View File
@@ -0,0 +1,77 @@
import type { Configuration as WebpackDevServerConfiguration } from 'webpack-dev-server';
export const RSPACK_SUPPORTED_PACKAGES = [
'@affine/admin',
'@affine/web',
'@affine/mobile',
'@affine/ios',
'@affine/android',
'@affine/electron-renderer',
'@affine/server',
] as const;
const rspackSupportedPackageSet = new Set<string>(RSPACK_SUPPORTED_PACKAGES);
export function isRspackSupportedPackageName(name: string) {
return rspackSupportedPackageSet.has(name);
}
export function assertRspackSupportedPackageName(name: string) {
if (isRspackSupportedPackageName(name)) {
return;
}
throw new Error(
`AFFINE_BUNDLER=rspack currently supports: ${Array.from(RSPACK_SUPPORTED_PACKAGES).join(', ')}. Use AFFINE_BUNDLER=webpack for ${name}.`
);
}
const IN_CI = !!process.env.CI;
const httpProxyMiddlewareLogLevel = IN_CI ? 'silent' : 'error';
export const DEFAULT_DEV_SERVER_CONFIG: WebpackDevServerConfiguration = {
host: '0.0.0.0',
allowedHosts: 'all',
hot: false,
liveReload: true,
compress: !process.env.CI,
setupExitSignals: true,
client: {
overlay: process.env.DISABLE_DEV_OVERLAY === 'true' ? false : undefined,
logging: process.env.CI ? 'none' : 'error',
// see: https://webpack.js.org/configuration/dev-server/#websocketurl
// must be an explicit ws/wss URL because custom protocols (e.g. assets://)
// cannot be used to construct WebSocket endpoints in Electron
webSocketURL: 'ws://0.0.0.0:8080/ws',
},
historyApiFallback: {
rewrites: [
{
from: /.*/,
to: () => {
return process.env.SELF_HOSTED === 'true'
? '/selfhost.html'
: '/index.html';
},
},
],
},
proxy: [
{
context: '/api',
target: 'http://localhost:3010',
logLevel: httpProxyMiddlewareLogLevel,
},
{
context: '/socket.io',
target: 'http://localhost:3010',
ws: true,
logLevel: httpProxyMiddlewareLogLevel,
},
{
context: '/graphql',
target: 'http://localhost:3010',
logLevel: httpProxyMiddlewareLogLevel,
},
],
};
+226 -70
View File
@@ -3,20 +3,46 @@ import { cpus } from 'node:os';
import { Logger } from '@affine-tools/utils/logger';
import { Package } from '@affine-tools/utils/workspace';
import rspack, { type MultiRspackOptions } from '@rspack/core';
import {
type Configuration as RspackDevServerConfiguration,
RspackDevServer,
} from '@rspack/dev-server';
import { merge } from 'lodash-es';
import webpack from 'webpack';
import WebpackDevServer, {
type Configuration as DevServerConfiguration,
type Configuration as WebpackDevServerConfiguration,
} from 'webpack-dev-server';
import {
assertRspackSupportedPackageName,
DEFAULT_DEV_SERVER_CONFIG,
isRspackSupportedPackageName,
} from './bundle-shared';
import { type Bundler, getBundler } from './bundler';
import { Option, PackageCommand } from './command';
import {
createHTMLTargetConfig,
createNodeTargetConfig,
createWorkerTargetConfig,
createHTMLTargetConfig as createRspackHTMLTargetConfig,
createNodeTargetConfig as createRspackNodeTargetConfig,
createWorkerTargetConfig as createRspackWorkerTargetConfig,
} from './rspack';
import {
createHTMLTargetConfig as createWebpackHTMLTargetConfig,
createNodeTargetConfig as createWebpackNodeTargetConfig,
createWorkerTargetConfig as createWebpackWorkerTargetConfig,
} from './webpack';
function getBaseWorkerConfigs(pkg: Package) {
type WorkerConfig = { name: string };
type CreateWorkerTargetConfig = (pkg: Package, entry: string) => WorkerConfig;
function assertRspackSupportedPackage(pkg: Package) {
assertRspackSupportedPackageName(pkg.name);
}
function getBaseWorkerConfigs(
pkg: Package,
createWorkerTargetConfig: CreateWorkerTargetConfig
) {
const core = new Package('@affine/core');
return [
@@ -39,27 +65,30 @@ function getBaseWorkerConfigs(pkg: Package) {
];
}
function getBundleConfigs(pkg: Package): webpack.MultiConfiguration {
function getWebpackBundleConfigs(pkg: Package): webpack.MultiConfiguration {
switch (pkg.name) {
case '@affine/admin': {
return [
createHTMLTargetConfig(pkg, pkg.srcPath.join('index.tsx').value),
createWebpackHTMLTargetConfig(pkg, pkg.srcPath.join('index.tsx').value),
] as webpack.MultiConfiguration;
}
case '@affine/web':
case '@affine/mobile':
case '@affine/ios':
case '@affine/android': {
const workerConfigs = getBaseWorkerConfigs(pkg);
const workerConfigs = getBaseWorkerConfigs(
pkg,
createWebpackWorkerTargetConfig
);
workerConfigs.push(
createWorkerTargetConfig(
createWebpackWorkerTargetConfig(
pkg,
pkg.srcPath.join('nbstore.worker.ts').value
)
);
return [
createHTMLTargetConfig(
createWebpackHTMLTargetConfig(
pkg,
pkg.srcPath.join('index.tsx').value,
{},
@@ -69,10 +98,13 @@ function getBundleConfigs(pkg: Package): webpack.MultiConfiguration {
] as webpack.MultiConfiguration;
}
case '@affine/electron-renderer': {
const workerConfigs = getBaseWorkerConfigs(pkg);
const workerConfigs = getBaseWorkerConfigs(
pkg,
createWebpackWorkerTargetConfig
);
return [
createHTMLTargetConfig(
createWebpackHTMLTargetConfig(
pkg,
{
index: pkg.srcPath.join('app/index.tsx').value,
@@ -93,7 +125,7 @@ function getBundleConfigs(pkg: Package): webpack.MultiConfiguration {
}
case '@affine/server': {
return [
createNodeTargetConfig(pkg, pkg.srcPath.join('index.ts').value),
createWebpackNodeTargetConfig(pkg, pkg.srcPath.join('index.ts').value),
] as webpack.MultiConfiguration;
}
}
@@ -101,55 +133,75 @@ function getBundleConfigs(pkg: Package): webpack.MultiConfiguration {
throw new Error(`Unsupported package: ${pkg.name}`);
}
const IN_CI = !!process.env.CI;
const httpProxyMiddlewareLogLevel = IN_CI ? 'silent' : 'error';
function getRspackBundleConfigs(pkg: Package): MultiRspackOptions {
assertRspackSupportedPackage(pkg);
const defaultDevServerConfig: DevServerConfiguration = {
host: '0.0.0.0',
allowedHosts: 'all',
hot: false,
liveReload: true,
compress: !process.env.CI,
setupExitSignals: true,
client: {
overlay: process.env.DISABLE_DEV_OVERLAY === 'true' ? false : undefined,
logging: process.env.CI ? 'none' : 'error',
// see: https://webpack.js.org/configuration/dev-server/#websocketurl
// must be an explicit ws/wss URL because custom protocols (e.g. assets://)
// cannot be used to construct WebSocket endpoints in Electron
webSocketURL: 'ws://0.0.0.0:8080/ws',
},
historyApiFallback: {
rewrites: [
{
from: /.*/,
to: () => {
return process.env.SELF_HOSTED === 'true'
? '/selfhost.html'
: '/index.html';
},
},
],
},
proxy: [
{
context: '/api',
target: 'http://localhost:3010',
logLevel: httpProxyMiddlewareLogLevel,
},
{
context: '/socket.io',
target: 'http://localhost:3010',
ws: true,
logLevel: httpProxyMiddlewareLogLevel,
},
{
context: '/graphql',
target: 'http://localhost:3010',
logLevel: httpProxyMiddlewareLogLevel,
},
],
};
switch (pkg.name) {
case '@affine/admin': {
return [
createRspackHTMLTargetConfig(pkg, pkg.srcPath.join('index.tsx').value),
] as MultiRspackOptions;
}
case '@affine/web':
case '@affine/mobile':
case '@affine/ios':
case '@affine/android': {
const workerConfigs = getBaseWorkerConfigs(
pkg,
createRspackWorkerTargetConfig
);
workerConfigs.push(
createRspackWorkerTargetConfig(
pkg,
pkg.srcPath.join('nbstore.worker.ts').value
)
);
return [
createRspackHTMLTargetConfig(
pkg,
pkg.srcPath.join('index.tsx').value,
{},
workerConfigs.map(config => config.name)
),
...workerConfigs,
] as MultiRspackOptions;
}
case '@affine/electron-renderer': {
const workerConfigs = getBaseWorkerConfigs(
pkg,
createRspackWorkerTargetConfig
);
return [
createRspackHTMLTargetConfig(
pkg,
{
index: pkg.srcPath.join('app/index.tsx').value,
shell: pkg.srcPath.join('shell/index.tsx').value,
popup: pkg.srcPath.join('popup/index.tsx').value,
backgroundWorker: pkg.srcPath.join('background-worker/index.ts')
.value,
},
{
additionalEntryForSelfhost: false,
injectGlobalErrorHandler: false,
emitAssetsManifest: false,
},
workerConfigs.map(config => config.name)
),
...workerConfigs,
] as MultiRspackOptions;
}
case '@affine/server': {
return [
createRspackNodeTargetConfig(pkg, pkg.srcPath.join('index.ts').value),
] as MultiRspackOptions;
}
}
throw new Error(`Unsupported package: ${pkg.name}`);
}
export class BundleCommand extends PackageCommand {
static override paths = [['bundle'], ['webpack'], ['pack'], ['bun']];
@@ -164,22 +216,36 @@ export class BundleCommand extends PackageCommand {
async execute() {
const pkg = this.workspace.getPackage(this.package);
const bundler = getBundler();
if (this.dev) {
await BundleCommand.dev(pkg);
await BundleCommand.dev(pkg, bundler);
} else {
await BundleCommand.build(pkg);
await BundleCommand.build(pkg, bundler);
}
}
static async build(pkg: Package) {
static async build(pkg: Package, bundler: Bundler = getBundler()) {
if (bundler === 'rspack' && !isRspackSupportedPackageName(pkg.name)) {
return BundleCommand.buildWithWebpack(pkg);
}
switch (bundler) {
case 'webpack':
return BundleCommand.buildWithWebpack(pkg);
case 'rspack':
return BundleCommand.buildWithRspack(pkg);
}
}
static async buildWithWebpack(pkg: Package) {
process.env.NODE_ENV = 'production';
const logger = new Logger('bundle');
logger.info(`Packing package ${pkg.name}...`);
logger.info(`Packing package ${pkg.name} with webpack...`);
logger.info('Cleaning old output...');
rmSync(pkg.distPath.value, { recursive: true, force: true });
const config = getBundleConfigs(pkg);
const config = getWebpackBundleConfigs(pkg);
config.parallelism = cpus().length;
const compiler = webpack(config);
@@ -203,12 +269,43 @@ export class BundleCommand extends PackageCommand {
});
}
static async dev(pkg: Package, devServerConfig?: DevServerConfiguration) {
static async dev(
pkg: Package,
bundler: Bundler = getBundler(),
devServerConfig?:
| WebpackDevServerConfiguration
| RspackDevServerConfiguration
) {
if (bundler === 'rspack' && !isRspackSupportedPackageName(pkg.name)) {
return BundleCommand.devWithWebpack(
pkg,
devServerConfig as WebpackDevServerConfiguration | undefined
);
}
switch (bundler) {
case 'webpack':
return BundleCommand.devWithWebpack(
pkg,
devServerConfig as WebpackDevServerConfiguration | undefined
);
case 'rspack':
return BundleCommand.devWithRspack(
pkg,
devServerConfig as RspackDevServerConfiguration | undefined
);
}
}
static async devWithWebpack(
pkg: Package,
devServerConfig?: WebpackDevServerConfiguration
) {
process.env.NODE_ENV = 'development';
const logger = new Logger('bundle');
logger.info(`Starting dev server for ${pkg.name}...`);
logger.info(`Starting webpack dev server for ${pkg.name}...`);
const config = getBundleConfigs(pkg);
const config = getWebpackBundleConfigs(pkg);
config.parallelism = cpus().length;
const compiler = webpack(config);
@@ -217,7 +314,66 @@ export class BundleCommand extends PackageCommand {
}
const devServer = new WebpackDevServer(
merge({}, defaultDevServerConfig, devServerConfig),
merge({}, DEFAULT_DEV_SERVER_CONFIG, devServerConfig),
compiler
);
await devServer.start();
}
static async buildWithRspack(pkg: Package) {
process.env.NODE_ENV = 'production';
assertRspackSupportedPackage(pkg);
const logger = new Logger('bundle');
logger.info(`Packing package ${pkg.name} with rspack...`);
logger.info('Cleaning old output...');
rmSync(pkg.distPath.value, { recursive: true, force: true });
const config = getRspackBundleConfigs(pkg);
config.parallelism = cpus().length;
const compiler = rspack(config);
if (!compiler) {
throw new Error('Failed to create rspack compiler');
}
compiler.run((error, stats) => {
if (error) {
console.error(error);
process.exit(1);
}
if (stats) {
if (stats.hasErrors()) {
console.error(stats.toString('errors-only'));
process.exit(1);
} else {
console.log(stats.toString('minimal'));
}
}
});
}
static async devWithRspack(
pkg: Package,
devServerConfig?: RspackDevServerConfiguration
) {
process.env.NODE_ENV = 'development';
assertRspackSupportedPackage(pkg);
const logger = new Logger('bundle');
logger.info(`Starting rspack dev server for ${pkg.name}...`);
const config = getRspackBundleConfigs(pkg);
config.parallelism = cpus().length;
const compiler = rspack(config);
if (!compiler) {
throw new Error('Failed to create rspack compiler');
}
const devServer = new RspackDevServer(
merge({}, DEFAULT_DEV_SERVER_CONFIG, devServerConfig),
compiler
);
+27
View File
@@ -0,0 +1,27 @@
export const SUPPORTED_BUNDLERS = ['webpack', 'rspack'] as const;
export type Bundler = (typeof SUPPORTED_BUNDLERS)[number];
export const DEFAULT_BUNDLER: Bundler = 'rspack';
function isBundler(value: string): value is Bundler {
return SUPPORTED_BUNDLERS.includes(value as Bundler);
}
export function normalizeBundler(input: string | undefined | null): Bundler {
const value = input?.trim().toLowerCase();
if (!value) {
return DEFAULT_BUNDLER;
}
if (isBundler(value)) {
return value;
}
throw new Error(
`Unsupported AFFINE_BUNDLER: "${input}". Expected one of: ${SUPPORTED_BUNDLERS.join(', ')}.`
);
}
export function getBundler(env: NodeJS.ProcessEnv = process.env): Bundler {
return normalizeBundler(env.AFFINE_BUNDLER);
}
+638
View File
@@ -0,0 +1,638 @@
import { createRequire } from 'node:module';
import path from 'node:path';
import { getBuildConfig } from '@affine-tools/utils/build-config';
import { Path, ProjectRoot } from '@affine-tools/utils/path';
import { Package } from '@affine-tools/utils/workspace';
import rspack, {
type Configuration as RspackConfiguration,
} from '@rspack/core';
import { sentryWebpackPlugin } from '@sentry/webpack-plugin';
import { VanillaExtractPlugin } from '@vanilla-extract/webpack-plugin';
import cssnano from 'cssnano';
import { compact, merge } from 'lodash-es';
import { productionCacheGroups } from '../webpack/cache-group.js';
import {
type CreateHTMLPluginConfig,
createHTMLPlugins as createWebpackCompatibleHTMLPlugins,
} from '../webpack/html-plugin.js';
import { WebpackS3Plugin } from '../webpack/s3-plugin.js';
const require = createRequire(import.meta.url);
const IN_CI = !!process.env.CI;
const availableChannels = ['canary', 'beta', 'stable', 'internal'];
function getBuildConfigFromEnv(pkg: Package) {
const channel = process.env.BUILD_TYPE ?? 'canary';
const dev = process.env.NODE_ENV === 'development';
if (!availableChannels.includes(channel)) {
throw new Error(
`BUILD_TYPE must be one of ${availableChannels.join(', ')}, received [${channel}]`
);
}
return getBuildConfig(pkg, {
// @ts-expect-error checked
channel,
mode: dev ? 'development' : 'production',
});
}
export function createHTMLTargetConfig(
pkg: Package,
entry: string | Record<string, string>,
htmlConfig: Partial<CreateHTMLPluginConfig> = {},
deps?: string[]
): RspackConfiguration {
entry = typeof entry === 'string' ? { index: entry } : entry;
htmlConfig = merge(
{},
{
filename: 'index.html',
additionalEntryForSelfhost: true,
injectGlobalErrorHandler: true,
emitAssetsManifest: true,
},
htmlConfig
);
const buildConfig = getBuildConfigFromEnv(pkg);
console.log(
`Building [${pkg.name}] for [${buildConfig.appBuildType}] channel in [${buildConfig.debug ? 'development' : 'production'}] mode.`
);
console.log(
`Entry points: ${Object.entries(entry)
.map(([name, path]) => `${name}: ${path}`)
.join(', ')}`
);
console.log(`Output path: ${pkg.distPath.value}`);
console.log(`Config: ${JSON.stringify(buildConfig, null, 2)}`);
const config: RspackConfiguration = {
//#region basic webpack config
name: entry['index'],
dependencies: deps,
context: ProjectRoot.value,
experiments: {
topLevelAwait: true,
outputModule: false,
asyncWebAssembly: true,
},
entry,
output: {
environment: { module: true, dynamicImport: true },
filename: buildConfig.debug
? 'js/[name].js'
: 'js/[name].[contenthash:8].js',
assetModuleFilename: buildConfig.debug
? '[name].[contenthash:8][ext]'
: 'assets/[name].[contenthash:8][ext][query]',
path: pkg.distPath.value,
clean: false,
globalObject: 'globalThis',
// NOTE: always keep it '/'
publicPath: '/',
},
target: ['web', 'es2022'],
mode: buildConfig.debug ? 'development' : 'production',
devtool: buildConfig.debug ? 'cheap-module-source-map' : 'source-map',
resolve: {
symlinks: true,
extensionAlias: {
'.js': ['.js', '.tsx', '.ts'],
'.mjs': ['.mjs', '.mts'],
},
extensions: ['.js', '.ts', '.tsx'],
alias: {
yjs: ProjectRoot.join('node_modules', 'yjs').value,
lit: ProjectRoot.join('node_modules', 'lit').value,
'@preact/signals-core': ProjectRoot.join(
'node_modules',
'@preact',
'signals-core'
).value,
},
},
//#endregion
//#region module config
module: {
parser: {
javascript: {
// Do not mock Node.js globals
node: false,
requireJs: false,
import: true,
// Treat as missing export as error
strictExportPresence: true,
},
},
//#region rules
rules: [
{ test: /\.m?js?$/, resolve: { fullySpecified: false } },
{
test: /\.js$/,
enforce: 'pre',
include: /@blocksuite/,
use: ['source-map-loader'],
},
{
oneOf: [
{
test: /\.ts$/,
exclude: /node_modules/,
loader: 'swc-loader',
options: {
// https://swc.rs/docs/configuring-swc/
jsc: {
preserveAllComments: true,
parser: {
syntax: 'typescript',
dynamicImport: true,
topLevelAwait: false,
tsx: false,
decorators: true,
},
target: 'es2022',
externalHelpers: false,
transform: {
useDefineForClassFields: false,
decoratorVersion: '2022-03',
},
},
sourceMaps: true,
inlineSourcesContent: true,
},
},
{
test: /\.tsx$/,
exclude: /node_modules/,
loader: 'swc-loader',
options: {
// https://swc.rs/docs/configuring-swc/
jsc: {
preserveAllComments: true,
parser: {
syntax: 'typescript',
dynamicImport: true,
topLevelAwait: false,
tsx: true,
decorators: true,
},
target: 'es2022',
externalHelpers: false,
transform: {
react: { runtime: 'automatic' },
useDefineForClassFields: false,
decoratorVersion: '2022-03',
},
},
sourceMaps: true,
inlineSourcesContent: true,
},
},
{
test: /\.(png|jpg|gif|svg|webp|mp4|zip)$/,
type: 'asset/resource',
},
{ test: /\.(ttf|eot|woff|woff2)$/, type: 'asset/resource' },
{ test: /\.txt$/, type: 'asset/source' },
{ test: /\.inline\.svg$/, type: 'asset/inline' },
{
test: /\.css$/,
use: [
buildConfig.debug
? 'style-loader'
: rspack.CssExtractRspackPlugin.loader,
{
loader: 'css-loader',
options: {
url: true,
sourceMap: false,
modules: false,
import: true,
importLoaders: 1,
},
},
{
loader: 'postcss-loader',
options: {
postcssOptions: {
plugins: pkg.join('tailwind.config.js').exists()
? [
[
'@tailwindcss/postcss',
require(pkg.join('tailwind.config.js').value),
],
['autoprefixer'],
]
: [
cssnano({
preset: ['default', { convertValues: false }],
}),
],
},
},
},
],
},
],
},
],
//#endregion
},
//#endregion
//#region plugins
plugins: compact([
!IN_CI && new rspack.ProgressPlugin(),
...createWebpackCompatibleHTMLPlugins(buildConfig, htmlConfig),
new rspack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
...Object.entries(buildConfig).reduce(
(def, [k, v]) => {
def[`BUILD_CONFIG.${k}`] = JSON.stringify(v);
return def;
},
{} as Record<string, string>
),
}),
!buildConfig.debug &&
// todo: support multiple entry points
new rspack.CssExtractRspackPlugin({
filename: `[name].[contenthash:8].css`,
ignoreOrder: true,
}),
new VanillaExtractPlugin(),
!buildConfig.isAdmin &&
new rspack.CopyRspackPlugin({
patterns: [
{
// copy the shared public assets into dist
from: new Package('@affine/core').join('public').value,
},
],
}),
!buildConfig.debug &&
(buildConfig.isWeb || buildConfig.isMobileWeb || buildConfig.isAdmin) &&
process.env.R2_SECRET_ACCESS_KEY &&
new WebpackS3Plugin(),
process.env.SENTRY_AUTH_TOKEN &&
process.env.SENTRY_ORG &&
process.env.SENTRY_PROJECT &&
sentryWebpackPlugin({
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
authToken: process.env.SENTRY_AUTH_TOKEN,
}),
// sourcemap url like # sourceMappingURL=76-6370cd185962bc89.js.map wont load in electron
// this is because the default file:// protocol will be ignored by Chromium
// so we need to replace the sourceMappingURL to assets:// protocol
// for example:
// replace # sourceMappingURL=76-6370cd185962bc89.js.map
// to # sourceMappingURL=assets://./{dir}/76-6370cd185962bc89.js.map
buildConfig.isElectron &&
new rspack.SourceMapDevToolPlugin({
append: (pathData: { filename?: string }) => {
return `\n//# sourceMappingURL=assets://./${pathData.filename ?? ''}.map`;
},
filename: '[file].map',
}),
]),
//#endregion
stats: { errorDetails: true },
//#region optimization
optimization: {
minimize: !buildConfig.debug,
minimizer: [
new rspack.SwcJsMinimizerRspackPlugin({
extractComments: true,
minimizerOptions: {
ecma: 2020,
compress: { unused: true },
mangle: { keep_classnames: true },
},
}),
],
removeEmptyChunks: true,
providedExports: true,
usedExports: true,
sideEffects: true,
removeAvailableModules: true,
runtimeChunk: { name: 'runtime' },
splitChunks: {
chunks: 'all',
minSize: 1,
minChunks: 1,
maxInitialRequests: Number.MAX_SAFE_INTEGER,
maxAsyncRequests: Number.MAX_SAFE_INTEGER,
cacheGroups: {
...productionCacheGroups,
// Rspack tends to pull async node_modules into the initial vendor chunk
// when `vendor` is configured as `chunks: 'all'`.
vendor: {
...productionCacheGroups.vendor,
chunks: 'initial',
},
},
},
},
//#endregion
};
if (buildConfig.debug && !IN_CI) {
config.optimization = {
...config.optimization,
minimize: false,
runtimeChunk: false,
splitChunks: {
maxInitialRequests: Infinity,
chunks: 'all',
cacheGroups: {
defaultVendors: {
test: `[\\/]node_modules[\\/](?!.*vanilla-extract)`,
priority: -10,
reuseExistingChunk: true,
},
default: { minChunks: 2, priority: -20, reuseExistingChunk: true },
styles: {
name: 'styles',
type: 'css/mini-extract',
chunks: 'all',
enforce: true,
},
},
},
};
}
return config;
}
export function createWorkerTargetConfig(
pkg: Package,
entry: string
): Omit<RspackConfiguration, 'name'> & { name: string } {
const workerName = path.basename(entry).replace(/\.worker\.ts$/, '');
const buildConfig = getBuildConfigFromEnv(pkg);
return {
name: entry,
context: ProjectRoot.value,
experiments: {
topLevelAwait: true,
outputModule: false,
asyncWebAssembly: true,
},
entry: { [workerName]: entry },
output: {
filename: `js/${workerName}-${buildConfig.appVersion}.worker.js`,
path: pkg.distPath.value,
clean: false,
globalObject: 'globalThis',
// NOTE: always keep it '/'
publicPath: '/',
},
target: ['webworker', 'es2022'],
mode: buildConfig.debug ? 'development' : 'production',
devtool: buildConfig.debug ? 'cheap-module-source-map' : 'source-map',
resolve: {
symlinks: true,
extensionAlias: { '.js': ['.js', '.ts'], '.mjs': ['.mjs', '.mts'] },
extensions: ['.js', '.ts'],
alias: { yjs: ProjectRoot.join('node_modules', 'yjs').value },
},
module: {
parser: {
javascript: {
// Do not mock Node.js globals
node: false,
requireJs: false,
import: true,
// Treat as missing export as error
strictExportPresence: true,
},
},
rules: [
{ test: /\.m?js?$/, resolve: { fullySpecified: false } },
{
test: /\.js$/,
enforce: 'pre',
include: /@blocksuite/,
use: ['source-map-loader'],
},
{
oneOf: [
{
test: /\.ts$/,
exclude: /node_modules/,
loader: 'swc-loader',
options: {
// https://swc.rs/docs/configuring-swc/
jsc: {
preserveAllComments: true,
parser: {
syntax: 'typescript',
dynamicImport: true,
topLevelAwait: false,
tsx: false,
decorators: true,
},
target: 'es2022',
externalHelpers: false,
transform: {
useDefineForClassFields: false,
decoratorVersion: '2022-03',
},
},
sourceMaps: true,
inlineSourcesContent: true,
},
},
],
},
],
},
plugins: compact([
new rspack.DefinePlugin(
Object.entries(buildConfig).reduce(
(def, [k, v]) => {
def[`BUILD_CONFIG.${k}`] = JSON.stringify(v);
return def;
},
{} as Record<string, string>
)
),
new rspack.optimize.LimitChunkCountPlugin({ maxChunks: 1 }),
process.env.SENTRY_AUTH_TOKEN &&
process.env.SENTRY_ORG &&
process.env.SENTRY_PROJECT &&
sentryWebpackPlugin({
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
authToken: process.env.SENTRY_AUTH_TOKEN,
}),
]),
stats: { errorDetails: true },
optimization: {
minimize: !buildConfig.debug,
minimizer: [
new rspack.SwcJsMinimizerRspackPlugin({
extractComments: true,
minimizerOptions: {
ecma: 2020,
compress: { unused: true },
mangle: { keep_classnames: true },
},
}),
],
removeEmptyChunks: true,
providedExports: true,
usedExports: true,
sideEffects: true,
removeAvailableModules: true,
runtimeChunk: false,
splitChunks: false,
},
performance: { hints: false },
};
}
export function createNodeTargetConfig(
pkg: Package,
entry: string
): Omit<RspackConfiguration, 'name'> & { name: string } {
const dev = process.env.NODE_ENV === 'development';
return {
name: entry,
context: ProjectRoot.value,
experiments: {
topLevelAwait: true,
outputModule: pkg.packageJson.type === 'module',
asyncWebAssembly: true,
},
entry: { index: entry },
output: {
filename: `main.js`,
path: pkg.distPath.value,
clean: true,
globalObject: 'globalThis',
},
target: ['node', 'es2022'],
externals: ((data: any, callback: (err: null, value: boolean) => void) => {
if (
data.request &&
// import ... from 'module'
/^[a-zA-Z@]/.test(data.request) &&
// not workspace deps
!pkg.deps.some(dep => data.request!.startsWith(dep.name))
) {
callback(null, true);
} else {
callback(null, false);
}
}) as any,
externalsPresets: { node: true },
node: { __dirname: false, __filename: false },
mode: dev ? 'development' : 'production',
devtool: 'source-map',
resolve: {
symlinks: true,
extensionAlias: { '.js': ['.js', '.ts'], '.mjs': ['.mjs', '.mts'] },
extensions: ['.js', '.ts', '.tsx', '.node'],
alias: { yjs: ProjectRoot.join('node_modules', 'yjs').value },
},
module: {
parser: {
javascript: { url: false, importMeta: false, createRequire: false },
},
rules: [
{
test: /\.js$/,
enforce: 'pre',
include: /@blocksuite/,
use: ['source-map-loader'],
},
{
test: /\.node$/,
loader: Path.dir(import.meta.url).join('../webpack/node-loader.js')
.value,
},
{
test: /\.tsx?$/,
exclude: /node_modules/,
loader: 'swc-loader',
options: {
// https://swc.rs/docs/configuring-swc/
jsc: {
preserveAllComments: true,
parser: {
syntax: 'typescript',
dynamicImport: true,
topLevelAwait: true,
tsx: true,
decorators: true,
},
target: 'es2022',
externalHelpers: false,
transform: {
legacyDecorator: true,
decoratorMetadata: true,
react: { runtime: 'automatic' },
},
},
sourceMaps: true,
inlineSourcesContent: true,
},
},
],
},
plugins: compact([
new rspack.optimize.LimitChunkCountPlugin({ maxChunks: 1 }),
new rspack.IgnorePlugin({
checkResource(resource) {
const lazyImports = [
'@nestjs/microservices',
'@nestjs/websockets/socket-module',
'@apollo/subgraph',
'@apollo/gateway',
'@as-integrations/fastify',
'ts-morph',
'class-validator',
'class-transformer',
];
return lazyImports.some(lazyImport =>
resource.startsWith(lazyImport)
);
},
}),
new rspack.DefinePlugin({
'process.env.NODE_ENV': '"production"',
}),
]),
stats: { errorDetails: true },
optimization: {
nodeEnv: false,
minimize: !dev,
minimizer: [
new rspack.SwcJsMinimizerRspackPlugin({
extractComments: true,
minimizerOptions: {
ecma: 2020,
compress: { unused: true },
mangle: { keep_classnames: true },
},
}),
],
},
performance: { hints: false },
ignoreWarnings: [/^(?!CriticalDependenciesWarning$)/],
};
}
+9 -2
View File
@@ -21,11 +21,18 @@ export const productionCacheGroups = {
asyncVendor: {
test: /[\\/]node_modules[\\/]/,
name(module: any) {
const modulePath =
module?.nameForCondition?.() || module?.context || module?.resource;
if (!modulePath || typeof modulePath !== 'string') {
return 'app-async';
}
// monorepo linked in node_modules, so it's not a npm package
if (!module.context.includes('node_modules')) {
if (!modulePath.includes('node_modules')) {
return `app-async`;
}
const name = module.context.match(
const name = modulePath.match(
/[\\/]node_modules[\\/](.*?)([\\/]|$)/
)?.[1];
return `npm-async-${name}`;
+31 -7
View File
@@ -5,8 +5,31 @@ import { Path, ProjectRoot } from '@affine-tools/utils/path';
import { Repository } from '@napi-rs/simple-git';
import HTMLPlugin from 'html-webpack-plugin';
import { once } from 'lodash-es';
import type { Compiler, WebpackPluginInstance } from 'webpack';
import webpack from 'webpack';
import type { WebpackPluginInstance } from 'webpack';
type CompilerLike = {
webpack?: {
sources?: {
RawSource?: new (source: string) => unknown;
};
};
hooks: {
compilation: {
tap: (name: string, callback: (compilation: any) => void) => void;
};
};
};
function createRawSource(compiler: CompilerLike, source: string) {
const RawSource = compiler.webpack?.sources?.RawSource;
if (!RawSource) {
throw new Error(
'compiler.webpack.sources.RawSource is required for html plugin assets emission'
);
}
return new RawSource(source);
}
export const getPublicPath = (BUILD_CONFIG: BUILD_CONFIG_TYPE) => {
const { BUILD_TYPE } = process.env;
@@ -86,7 +109,7 @@ function getHTMLPluginOptions(BUILD_CONFIG: BUILD_CONFIG_TYPE) {
}
const AssetsManifestPlugin = {
apply(compiler: Compiler) {
apply(compiler: CompilerLike) {
compiler.hooks.compilation.tap('assets-manifest-plugin', compilation => {
HTMLPlugin.getHooks(compilation).beforeAssetTagGeneration.tap(
'assets-manifest-plugin',
@@ -94,7 +117,8 @@ const AssetsManifestPlugin = {
if (!compilation.getAsset('assets-manifest.json')) {
compilation.emitAsset(
`assets-manifest.json`,
new webpack.sources.RawSource(
createRawSource(
compiler,
JSON.stringify(
{
...arg.assets,
@@ -125,7 +149,7 @@ const AssetsManifestPlugin = {
};
const GlobalErrorHandlerPlugin = {
apply(compiler: Compiler) {
apply(compiler: CompilerLike) {
const globalErrorHandler = [
'js/global-error-handler.js',
readFileSync(currentDir.join('./error-handler.js').toString(), 'utf-8'),
@@ -140,7 +164,7 @@ const GlobalErrorHandlerPlugin = {
if (!compilation.getAsset(globalErrorHandler[0])) {
compilation.emitAsset(
globalErrorHandler[0],
new webpack.sources.RawSource(globalErrorHandler[1])
createRawSource(compiler, globalErrorHandler[1])
);
arg.assets.js.unshift(
arg.assets.publicPath + globalErrorHandler[0]
@@ -156,7 +180,7 @@ const GlobalErrorHandlerPlugin = {
};
const CorsPlugin = {
apply(compiler: Compiler) {
apply(compiler: CompilerLike) {
compiler.hooks.compilation.tap('html-js-cors-plugin', compilation => {
HTMLPlugin.getHooks(compilation).alterAssetTags.tap(
'html-js-cors-plugin',