From 85abf7d66a6db3d1405d5588f162cfb49e7200bb Mon Sep 17 00:00:00 2001 From: uwx Date: Mon, 13 Apr 2026 22:26:33 +0000 Subject: [PATCH] rspack conversion (cherry picked from commit b12cc016637f4bd0fc0608038e3933e2e52648c1) checkpoint (cherry picked from commit a75884edaa6525d773fa1647e463c71e3bd7ab6e) bypass babel-loader for node_modules separate babel plugins for web, remove dupe babel plugins, remove babel cache compression bump rspack --- eslint.config.mjs | 2 ++ justfile | 8 ++------ package.json | 25 ++++++++++++++++++------- rspack.config.ts | 356 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ scripts/post-web-build.js | 18 ++++++++++++++---- web/index.html | 12 ++++++------ web/static/fonts/InterVariable-Italic.woff2 | 0 web/static/fonts/InterVariable.woff2 | 0 yarn.lock | 740 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------------------------------- 9 file(s) changed, 1082 insertion(s)(+), 79 deletion(s)(-) diff --git a/eslint.config.mjs b/eslint.config.mjs --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -38,6 +38,8 @@ '*.e2e.ts', '*.e2e.tsx', 'eslint.config.mjs', '.jscodeshift/**', + 'rspack.config.ts', + 'scripts/post-web-build.js', ], }, diff --git a/justfile b/justfile --- a/justfile +++ b/justfile @@ -21,7 +21,7 @@ expo prebuild -p android [group('build')] build-web: && postbuild-web - yarn build-web + NODE_ENV=production rspack build [group('build')] build-android-sideload: prebuild-android @@ -34,10 +34,6 @@ ./gradlew app:assembleRelease [group('build')] postbuild-web: - # build system outputs some srcs and hrefs like src="static/" - # need to rewrite to be src="/static/" to handle non root pages - sed -i 's/\(src\|href\)="static/\1="\/static/g' web-build/index.html - # we need to copy the static iframe html to support youtube embeds cp -r bskyweb/static/iframe/ web-build/iframe # copy well-known files to support app deeplinks too @@ -59,7 +55,7 @@ yarn android [group('dev')] dev-web: - yarn web + rspack serve [group('dev')] dev-web-functions: build-web diff --git a/package.json b/package.json --- a/package.json +++ b/package.json @@ -33,10 +33,10 @@ "android": "expo run:android", "android:prod": "expo run:android --variant release", "android:profile": "BSKY_PROFILE=1 expo run:android --variant release", "ios": "expo run:ios", - "web": "expo start --web", + "web": "rspack serve", "use-build-number": "./scripts/useBuildNumberEnv.sh", "use-build-number-with-bump": "./scripts/useBuildNumberEnvWithBump.sh", - "build-web": "expo export:web && node ./scripts/post-web-build.js", + "build-web": "NODE_ENV=production rspack build && node ./scripts/post-web-build.js", "build-all": "yarn intl:build && yarn use-build-number-with-bump eas build --platform all", "build-ios": "yarn use-build-number-with-bump eas build -p ios", "build-android": "yarn use-build-number-with-bump eas build -p android", @@ -76,8 +76,7 @@ "update-extensions": "bash scripts/updateExtensions.sh", "export": "npx expo export --dump-sourcemap && yarn upload-native-sourcemaps", "upload-native-sourcemaps": "npx sentry-expo-upload-sourcemaps dist", "make-deploy-bundle": "bash scripts/bundleUpdate.sh", - "generate-webpack-stats-file": "EXPO_PUBLIC_GENERATE_STATS=1 yarn build-web", - "open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web", + "generate-stats-file": "GENERATE_STATS=1 yarn build-web", "icons:optimize": "svgo -f ./assets/icons" }, "dependencies": { @@ -137,7 +136,7 @@ "@tiptap/suggestion": "^2.9.1", "@types/invariant": "^2.2.37", "@types/lodash.throttle": "^4.1.9", "@types/node": "^20.14.3", - "@uwx/exif-be-gone-web": "1.6.6", + "@uwx/exif-be-gone-web": "1.6.7", "array.prototype.findlast": "^1.2.3", "babel-plugin-transform-remove-console": "^6.9.4", "base64-js": "^1.5.1", @@ -245,9 +244,11 @@ "uri-js": "^4.4.1", "zod": "^3.20.2" }, "devDependencies": { - "@babel/core": "^7.26.0", + "@babel/core": "7.25.2", + "@babel/plugin-transform-flow-strip-types": "^7.27.1", "@babel/preset-env": "^7.26.0", - "@babel/runtime": "^7.26.0", + "@babel/runtime": "7.25.9", + "@babel/traverse": "7.25.2", "@crowdin/cli": "^4.14.1", "@eslint/js": "^9.39.2", "@expo/config-plugins": "~54.0.4", @@ -256,7 +257,13 @@ "@lingui/cli": "^5.9.2", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.15", "@react-native/babel-preset": "0.81.5", "@react-native/typescript-config": "^0.81.5", + "@rolldown/plugin-babel": "^0.2.3", + "@rspack/cli": "^2.0.0-canary.20260120", + "@rspack/core": "^2.0.0-canary.20260120", + "@rspack/dev-server": "^2.0.0-beta.7", + "@sentry/vite-plugin": "^5.2.0", "@sentry/webpack-plugin": "^3.2.2", + "@swc/core": "^1.15.24", "@testing-library/react-native": "^13.2.0", "@types/chroma-js": "^3.1.2", "@types/culori": "^4.0.1", @@ -268,6 +275,7 @@ "@types/psl": "^1.1.1", "@types/react": "^19.1.12", "@types/react-dom": "^19.1.9", "babel-jest": "^29.7.0", + "babel-loader": "^10.1.1", "babel-plugin-module-resolver": "^5.0.2", "babel-plugin-react-compiler": "^19.1.0-rc.3", "babel-preset-expo": "~54.0.10", @@ -294,7 +302,10 @@ "lockfile-lint": "^4.14.0", "prettier": "^3.6.0", "react-native-dotenv": "^3.4.11", "react-refresh": "^0.14.0", + "rollup-plugin-define": "^1.0.1", + "rspack-manifest-plugin": "^5.2.1", "svgo": "^3.3.2", + "swc-loader": "^0.2.7", "ts-plugin-sort-import-suggestions": "^1.0.4", "typescript": "^6.0.2", "typescript-eslint": "^8.58.0", diff --git a/rspack.config.ts b/rspack.config.ts new file mode 100644 --- /dev/null +++ b/rspack.config.ts @@ -0,0 +1,356 @@ +import path from 'node:path' +import rspack from '@rspack/core' +import {RspackManifestPlugin} from 'rspack-manifest-plugin' +import {sentryWebpackPlugin} from '@sentry/webpack-plugin' +import {version} from './package.json' + +const GENERATE_STATS = process.env.GENERATE_STATS === '1' +const isProduction = process.env.NODE_ENV === 'production' + +// Collect all EXPO_PUBLIC_* env vars so they're available at build time, +// mirroring what @expo/webpack-config does automatically. +const expoPublicEnv = Object.fromEntries( + Object.entries(process.env) + .filter(([key]) => key.startsWith('EXPO_PUBLIC_')) + .map(([key, value]) => [`process.env.${key}`, JSON.stringify(value)]), +) + +// Packages in node_modules that ship untranspiled JSX/Flow/modern syntax +// and need to be run through babel-loader. +const TRANSPILE_MODULES = [ + 'react-native', + 'react-native-web', + '@react-native', + '@react-native-community', + 'expo', + '@expo', + '@unimodules', + 'unimodules', + '@discord', + 'react-navigation', + '@react-navigation', + 'native-base', + 'normalize-url', + 'react-native-svg', + '@sentry/react-native', + 'sentry-expo', + 'bcp-47-match', + 'nanoid', + 'react-native-web-webview', +] + +/** Matches a file path against the TRANSPILE_MODULES list. */ +function isTranspileModule(filePath: string) { + return TRANSPILE_MODULES.some(mod => { + const sep = path.sep === '\\' ? '\\\\' : '/' + const pattern = new RegExp(`node_modules${sep}${mod.replace('/', sep)}`) + return pattern.test(filePath) + }) +} + +/** @type {import('@rspack/core').Configuration} */ +module.exports = { + mode: isProduction ? 'production' : 'development', + devtool: isProduction ? 'source-map' : 'eval-cheap-module-source-map', + + entry: { + main: path.resolve(__dirname, 'index.web.js'), + }, + + output: { + path: path.resolve(__dirname, 'web-build'), + filename: isProduction + ? 'static/js/[name].[contenthash:8].js' + : 'static/js/[name].js', + chunkFilename: isProduction + ? 'static/js/[name].[contenthash:8].chunk.js' + : 'static/js/[name].chunk.js', + assetModuleFilename: 'static/media/[name].[hash:8][ext]', + publicPath: isProduction ? 'auto' : '/', + clean: true, + }, + + resolve: { + extensions: [ + '.web.tsx', + '.web.ts', + '.web.js', + '.web.jsx', + '.tsx', + '.ts', + '.jsx', + '.js', + '.json', + ], + alias: { + // Path alias for src/ + '#': path.resolve(__dirname, 'src'), + // React Native Web + 'react-native$': 'react-native-web', + // Internal RN module mappings for compatibility + 'react-native/Libraries/Components/View/ViewStylePropTypes$': + 'react-native-web/dist/exports/View/ViewStylePropTypes', + 'react-native/Libraries/EventEmitter/RCTDeviceEventEmitter$': + 'react-native-web/dist/vendor/react-native/NativeEventEmitter/RCTDeviceEventEmitter', + 'react-native/Libraries/vendor/emitter/EventEmitter$': + 'react-native-web/dist/vendor/react-native/emitter/EventEmitter', + 'react-native/Libraries/EventEmitter/NativeEventEmitter$': + 'react-native-web/dist/vendor/react-native/NativeEventEmitter', + // Webview shim + 'react-native-webview': 'react-native-web-webview', + // Crypto shim for expo-modules-core + crypto: path.resolve(__dirname, 'src/platform/crypto.ts'), + // Force ESM version of unicode-segmenter + 'unicode-segmenter/grapheme': require + .resolve('unicode-segmenter/grapheme') + .replace(/\.cjs$/, '.js'), + // Block packages that should not load on web + 'react-native-gesture-handler': false, + '@sentry-internal/replay': false, + }, + mainFields: ['browser', 'module', 'main'], + // Allow importing without file extensions in ESM packages + fullySpecified: false, + }, + + module: { + rules: [ + // Disable fullySpecified for ESM packages that import without extensions + // (e.g. react-navigation importing react-native-web/dist/exports/Platform) + { + test: /\.m?js$/, + resolve: { + fullySpecified: false, + }, + }, + // Source files: use babel-loader for lingui macros, react-compiler, etc. + { + test: /\.[jt]sx?$/, + exclude: /node_modules/, + use: { + loader: 'babel-loader', + options: { + configFile: false, // Don't look for a babel.config.json to avoid conflicts with the one in the root of the monorepo. + babelrc: false, + cacheDirectory: true, + cacheCompression: false, // let rspack handle it + sourceType: 'unambiguous', + // based on babel.config.js but optimized for web and rspack + presets: [ + [ + 'babel-preset-expo', + { + lazyImports: true, + native: { + // Disable ESM -> CJS compilation because rspack handles it. + disableImportExportTransform: true, + }, + }, + ], + ], + plugins: [ + '@lingui/babel-plugin-lingui-macro', + ['babel-plugin-react-compiler', {target: '19'}], + // omitted: react-native-dotenv (we use DefinePlugin instead) + // omitted: module-resolver (we use rspack's built-in aliasing instead) + 'react-native-reanimated/plugin', // NOTE: this plugin MUST be last + ], + env: { + production: { + plugins: [], // omitted: transform-remove-console + }, + }, + }, + }, + }, + // node_modules that ship untranspiled JSX/Flow: use rspack's builtin + // SWC loader which is much faster than babel for simple transforms. + { + test: /\.jsx?$/, + include: isTranspileModule, + use: { + loader: 'swc-loader', // rspack swc-loader doesn't support flow yet + options: { + jsc: { + parser: { + syntax: 'flow', + jsx: true, + }, + transform: { + react: { + runtime: 'automatic', + }, + }, + }, + }, + }, + }, + { + test: /\.tsx?$/, + include: isTranspileModule, + use: { + loader: 'builtin:swc-loader', + options: { + jsc: { + parser: { + syntax: 'typescript', + jsx: true, + }, + transform: { + react: { + runtime: 'automatic', + }, + }, + }, + }, + }, + }, + // HTML file loader for react-native-web-webview's postMock.html + { + test: /postMock\.html$/, + type: 'asset/resource', + generator: { + filename: 'static/[name][ext]', + }, + }, + // CSS support — imported from JS/TS files + { + test: /\.css$/, + type: 'css/auto', + }, + // Image assets + { + test: /\.(bmp|gif|jpe?g|png|svg|avif|webp)$/i, + type: 'asset', + parser: { + dataUrlCondition: { + maxSize: 8 * 1024, // 8KB + }, + }, + }, + // Font assets + { + test: /\.(woff|woff2|otf|ttf|eot)$/i, + type: 'asset/resource', + }, + ], + }, + + plugins: [ + new rspack.HtmlRspackPlugin({ + template: path.resolve(__dirname, 'web/index.html'), + inject: true, + }), + new rspack.CopyRspackPlugin({ + patterns: [ + // Serve fonts at /static/fonts/ with stable names + {from: 'web/static/fonts', to: 'static/fonts'}, + // Serve the global stylesheet + {from: 'src/style.css', to: 'static/style.css'}, + ], + }), + new rspack.DefinePlugin({ + __DEV__: JSON.stringify(!isProduction), + 'process.env.NODE_ENV': JSON.stringify( + isProduction ? 'production' : 'development', + ), + 'process.env.JEST_WORKER_ID': JSON.stringify(undefined), + 'process.env.LIVE_EVENTS_DEV_URL': JSON.stringify( + process.env.LIVE_EVENTS_DEV_URL || '', + ), + 'process.env.APP_CONFIG_DEV_URL': JSON.stringify( + process.env.APP_CONFIG_DEV_URL || '', + ), + // provide sensible defaults for env vars that the web build expects but aren't defined in the environment + 'process.env.EXPO_PUBLIC_ENV': JSON.stringify( + isProduction ? 'production' : 'development', + ), + 'process.env.EAS_BUILD_PLATFORM': JSON.stringify('web'), + 'process.env.SENTRY_AUTH_TOKEN': 'undefined', + 'process.env.EXPO_PUBLIC_RELEASE_VERSION': 'undefined', + 'process.env.EXPO_PUBLIC_LOG_LEVEL': '"debug"', + 'process.env.EXPO_PUBLIC_LOG_DEBUG': '"*"', + 'process.env.EXPO_PUBLIC_OAUTH_BASE_URL': 'undefined', + 'process.env.EXPO_PUBLIC_OAUTH_CLIENT_NAME': 'undefined', + 'process.env.EXPO_PUBLIC_BUNDLE_IDENTIFIER': 'undefined', + 'process.env.EXPO_PUBLIC_BUNDLE_DATE': 'undefined', + 'process.env.EXPO_PUBLIC_SENTRY_DSN': 'undefined', + 'process.env.EXPO_PUBLIC_BLUESKY_PROXY_DID': 'undefined', + 'process.env.EXPO_PUBLIC_CHAT_PROXY_DID': 'undefined', + 'process.env.EXPO_PUBLIC_METRICS_API_HOST': 'undefined', + 'process.env.EXPO_PUBLIC_GROWTHBOOK_API_HOST': 'undefined', + 'process.env.EXPO_PUBLIC_GROWTHBOOK_CLIENT_KEY': 'undefined', + 'process.env.EXPO_PUBLIC_BITDRIFT_API_KEY': 'undefined', + 'process.env.EXPO_PUBLIC_GCP_PROJECT_ID': 'undefined', + 'process.env.EXPO_PUBLIC_PUBLIC_BSKY_SERVICE': 'undefined', + 'process.env.EXPO_PUBLIC_APPVIEW_DID_PROXY': 'undefined', + 'process.env.APP_MANIFEST': 'undefined', + 'process.env.__SENTRY_METRO_DEV_SERVER__': 'undefined', + 'process.env.EXPO_OS': JSON.stringify('web'), + // Inject all EXPO_PUBLIC_* env vars + ...expoPublicEnv, + }), + // Generate asset-manifest.json matching the format the Go server expects. + // The post-web-build script reads `entrypoints` from this manifest. + new RspackManifestPlugin({ + fileName: 'asset-manifest.json', + generate: (seed, files, entrypoints) => { + const entrypointFiles = entrypoints.main || [] + return { + files: files.reduce((manifest, file) => { + manifest[file.name] = file.path + return manifest + }, seed), + entrypoints: entrypointFiles.filter(f => !f.endsWith('.map')), + } + }, + }), + // Sentry source maps + isProduction && + process.env.SENTRY_AUTH_TOKEN && + sentryWebpackPlugin({ + org: 'blueskyweb', + project: 'app', + authToken: process.env.SENTRY_AUTH_TOKEN, + release: { + name: process.env.SENTRY_RELEASE || version, + dist: process.env.SENTRY_DIST, + }, + }), + ].filter(Boolean), + + optimization: { + splitChunks: { + chunks: 'all', + cacheGroups: { + vendor: { + test: /[\\/]node_modules[\\/]/, + name: 'vendor', + chunks: 'all', + priority: -10, + }, + }, + }, + runtimeChunk: 'single', + minimize: isProduction, + }, + + devServer: { + static: { + directory: path.resolve(__dirname, 'web'), + }, + port: 19006, + hot: true, + historyApiFallback: true, + compress: true, + }, + + // Don't bundle node built-ins (shouldn't be needed on web) + externalsPresets: {node: false}, + + stats: GENERATE_STATS ? 'verbose' : 'normal', + + experiments: { + css: true, + }, +} diff --git a/scripts/post-web-build.js b/scripts/post-web-build.js --- a/scripts/post-web-build.js +++ b/scripts/post-web-build.js @@ -9,9 +9,10 @@ 'templates', 'scripts.html', ) -const {entrypoints} = require( +const manifest = require( path.join(projectRoot, 'web-build/asset-manifest.json'), ) +const entrypoints = manifest.entrypoints || [] console.log(`Found ${entrypoints.length} entrypoints`) console.log(`Writing ${templateFile}`) @@ -34,10 +35,19 @@ .join('\n') fs.writeFileSync(templateFile, outputFile) function copyFiles(sourceDir, targetDir) { - const files = fs.readdirSync(path.join(projectRoot, sourceDir)) + const srcPath = path.join(projectRoot, sourceDir) + if (!fs.existsSync(srcPath)) { + console.log(`Skipping ${sourceDir} (does not exist)`) + return + } + const tgtPath = path.join(projectRoot, targetDir) + if (!fs.existsSync(tgtPath)) { + fs.mkdirSync(tgtPath, {recursive: true}) + } + const files = fs.readdirSync(srcPath) files.forEach(file => { - const sourcePath = path.join(projectRoot, sourceDir, file) - const targetPath = path.join(projectRoot, targetDir, file) + const sourcePath = path.join(srcPath, file) + const targetPath = path.join(tgtPath, file) fs.copyFileSync(sourcePath, targetPath) console.log(`Copied ${sourcePath} to ${targetPath}`) }) diff --git a/web/index.html b/web/index.html --- a/web/index.html +++ b/web/index.html @@ -17,9 +17,9 @@ Preconnect to essential domains --> - %WEB_TITLE% + Witchsky - +