Bluesky app fork with some witchin' additions 💫
1import path from 'node:path'
2import rspack from '@rspack/core'
3import {RspackManifestPlugin} from 'rspack-manifest-plugin'
4import {sentryWebpackPlugin} from '@sentry/webpack-plugin'
5import {version} from './package.json'
6import {existsSync, readdirSync, symlink} from 'node:fs'
7
8const GENERATE_STATS = process.env.GENERATE_STATS === '1'
9const isProduction = process.env.NODE_ENV === 'production'
10
11// Collect all EXPO_PUBLIC_* env vars so they're available at build time,
12// mirroring what @expo/webpack-config does automatically.
13const expoPublicEnv = Object.fromEntries(
14 Object.entries(process.env)
15 .filter(([key]) => key.startsWith('EXPO_PUBLIC_'))
16 .map(([key, value]) => [`process.env.${key}`, JSON.stringify(value)]),
17)
18
19// Packages in node_modules that ship untranspiled JSX/Flow/modern syntax
20// and need to be run through SWC.
21const TRANSPILE_MODULES = {
22 prefixes: [
23 'react-native',
24 'react-native-web',
25 'expo',
26 'unimodules',
27 'react-navigation',
28 ],
29 scopes: [
30 '@react-native',
31 '@react-native-community',
32 '@expo',
33 '@unimodules',
34 '@bsky.app',
35 '@discord',
36 '@react-navigation',
37 ],
38 packages: [
39 'native-base',
40 'normalize-url',
41 '@sentry/react-native',
42 'sentry-expo',
43 'bcp-47-match',
44 'nanoid',
45 ],
46}
47
48function getTranspileModuleDirs({
49 prefixes,
50 scopes,
51 packages,
52}: typeof TRANSPILE_MODULES) {
53 const nodeModulesDir = path.resolve(__dirname, 'node_modules')
54 const dirs = new Set<string>()
55
56 const readDirNames = (dir: string) => {
57 if (!existsSync(dir)) return []
58 return readdirSync(dir, {withFileTypes: true})
59 .filter(entry => entry.isDirectory())
60 .map(entry => entry.name)
61 }
62
63 for (const entry of readDirNames(nodeModulesDir)) {
64 console.log('Checking node_modules entry:', entry)
65 if (
66 prefixes.some(
67 prefix => entry === prefix || entry.startsWith(`${prefix}-`),
68 )
69 ) {
70 dirs.add(path.join(nodeModulesDir, entry))
71 }
72 }
73
74 for (const scope of scopes) {
75 const scopeDir = path.join(nodeModulesDir, scope)
76 for (const entry of readDirNames(scopeDir)) {
77 dirs.add(path.join(scopeDir, entry))
78 }
79 }
80
81 for (const pkg of packages) {
82 const pkgDir = path.join(nodeModulesDir, ...pkg.split('/'))
83 if (existsSync(pkgDir)) {
84 dirs.add(pkgDir)
85 }
86 }
87
88 return [...dirs]
89}
90
91const transpileModuleDirs = getTranspileModuleDirs(TRANSPILE_MODULES)
92const REANIMATED_BABEL_MODULES = ['react-native-keyboard-controller']
93const reanimatedBabelModuleDirs = REANIMATED_BABEL_MODULES.map(pkg =>
94 path.resolve(__dirname, 'node_modules', ...pkg.split('/')),
95).filter(existsSync)
96const SWC_TRANSPILE_EXCLUDE = /node_modules[\\/]react-native-uuid[\\/]/
97const REANIMATED_BABEL_EXCLUDE =
98 /node_modules[\\/]react-native-keyboard-controller[\\/]/
99
100/** @type {import('@rspack/core').Configuration} */
101module.exports = {
102 mode: isProduction ? 'production' : 'development',
103 // Avoid eval-based sourcemaps in development. Firefox resolves relative
104 // sourcemap URLs from injected devtools scripts like `installHook.js.map`
105 // against an `<anonymous code>` URL when the bundle is eval-backed, which
106 // produces noisy 404s in the console.
107 devtool: isProduction ? 'source-map' : 'cheap-module-source-map',
108
109 entry: {
110 main: path.resolve(__dirname, 'index.web.js'),
111 },
112
113 output: {
114 path: path.resolve(__dirname, 'web-build'),
115 filename: isProduction
116 ? 'static/js/[name].[contenthash:8].js'
117 : 'static/js/[name].js',
118 chunkFilename: isProduction
119 ? 'static/js/[name].[contenthash:8].chunk.js'
120 : 'static/js/[name].chunk.js',
121 assetModuleFilename: 'static/media/[name].[hash:8][ext]',
122 publicPath: isProduction ? 'auto' : '/',
123 clean: true,
124 },
125
126 resolve: {
127 extensions: [
128 '.web.tsx',
129 '.web.ts',
130 '.web.js',
131 '.web.jsx',
132 '.tsx',
133 '.ts',
134 '.jsx',
135 '.js',
136 '.json',
137 ],
138 alias: {
139 // Path alias for src/
140 '#': path.resolve(__dirname, 'src'),
141 // React Native Web
142 'react-native$': 'react-native-web',
143 // Internal RN module mappings for compatibility
144 'react-native/Libraries/Components/View/ViewStylePropTypes$':
145 'react-native-web/dist/exports/View/ViewStylePropTypes',
146 'react-native/Libraries/EventEmitter/RCTDeviceEventEmitter$':
147 'react-native-web/dist/vendor/react-native/NativeEventEmitter/RCTDeviceEventEmitter',
148 'react-native/Libraries/vendor/emitter/EventEmitter$':
149 'react-native-web/dist/vendor/react-native/emitter/EventEmitter',
150 'react-native/Libraries/EventEmitter/NativeEventEmitter$':
151 'react-native-web/dist/vendor/react-native/NativeEventEmitter',
152 // Webview shim
153 'react-native-webview': 'react-native-web-webview',
154 // Crypto shim for expo-modules-core
155 crypto: path.resolve(__dirname, 'src/platform/crypto.ts'),
156 // Force ESM version of unicode-segmenter
157 'unicode-segmenter/grapheme': require
158 .resolve('unicode-segmenter/grapheme')
159 .replace(/\.cjs$/, '.js'),
160 // Block packages that should not load on web
161 'react-native-gesture-handler': false,
162 '@sentry-internal/replay': false,
163 },
164 mainFields: ['browser', 'module', 'main'],
165 // Allow importing without file extensions in ESM packages
166 fullySpecified: false,
167 symlinks: false, // Don't resolve symlinks to support pnpm's node_modules structure
168 },
169
170 module: {
171 rules: [
172 // Disable fullySpecified for ESM packages that import without extensions
173 // (e.g. react-navigation importing react-native-web/dist/exports/Platform)
174 {
175 test: /\.m?js$/,
176 resolve: {
177 fullySpecified: false,
178 },
179 },
180 // Source files: use babel-loader for lingui macros, react-compiler, etc.
181 {
182 test: /\.[jt]sx?$/,
183 exclude: /node_modules/,
184 use: {
185 loader: 'babel-loader',
186 options: {
187 configFile: false, // Don't look for a babel.config.json to avoid conflicts with the one in the root of the monorepo.
188 babelrc: false,
189 cacheDirectory: true,
190 cacheCompression: false, // let rspack handle it
191 sourceType: 'unambiguous',
192 // based on babel.config.js but optimized for web and rspack
193 presets: [
194 [
195 'babel-preset-expo',
196 {
197 lazyImports: true,
198 native: {
199 // Disable ESM -> CJS compilation because rspack handles it.
200 disableImportExportTransform: true,
201 },
202 },
203 ],
204 ],
205 plugins: [
206 '@lingui/babel-plugin-lingui-macro',
207 ['babel-plugin-react-compiler', {target: '19'}],
208 // omitted: react-native-dotenv (we use DefinePlugin instead)
209 // omitted: module-resolver (we use rspack's built-in aliasing instead)
210 'react-native-reanimated/plugin', // NOTE: this plugin MUST be last
211 ],
212 env: {
213 production: {
214 plugins: [], // omitted: transform-remove-console
215 },
216 },
217 },
218 },
219 },
220 // Some published packages ship raw `"worklet"` functions and must go
221 // through Babel with the Reanimated plugin on web, matching Expo's
222 // webpack pipeline. SWC alone leaves those functions unworkletized.
223 {
224 test: /\.[jt]sx?$/,
225 include: reanimatedBabelModuleDirs,
226 use: {
227 loader: 'babel-loader',
228 options: {
229 configFile: false,
230 babelrc: false,
231 cacheDirectory: true,
232 cacheCompression: false,
233 sourceType: 'unambiguous',
234 presets: [
235 [
236 'babel-preset-expo',
237 {
238 lazyImports: true,
239 native: {
240 disableImportExportTransform: true,
241 },
242 },
243 ],
244 ],
245 plugins: ['react-native-reanimated/plugin'],
246 },
247 },
248 },
249 // node_modules that ship untranspiled JSX/Flow: use rspack's builtin
250 // SWC loader which is much faster than babel for simple transforms.
251 {
252 test: /\.jsx?$/,
253 include: transpileModuleDirs,
254 exclude: [SWC_TRANSPILE_EXCLUDE, REANIMATED_BABEL_EXCLUDE],
255 use: {
256 loader: 'swc-loader', // rspack swc-loader doesn't support flow yet
257 options: {
258 jsc: {
259 parser: {
260 syntax: 'flow',
261 jsx: true,
262 },
263 transform: {
264 react: {
265 runtime: 'automatic',
266 },
267 },
268 },
269 },
270 },
271 },
272 {
273 test: /\.tsx?$/,
274 include: transpileModuleDirs,
275 exclude: [SWC_TRANSPILE_EXCLUDE, REANIMATED_BABEL_EXCLUDE],
276 use: {
277 loader: 'swc-loader',
278 options: {
279 jsc: {
280 parser: {
281 syntax: 'typescript',
282 jsx: true,
283 },
284 transform: {
285 react: {
286 runtime: 'automatic',
287 },
288 },
289 },
290 },
291 },
292 },
293 // HTML file loader for react-native-web-webview's postMock.html
294 {
295 test: /postMock\.html$/,
296 type: 'asset/resource',
297 generator: {
298 filename: 'static/[name][ext]',
299 },
300 },
301 // CSS support — imported from JS/TS files
302 {
303 test: /\.css$/,
304 type: 'css/auto',
305 },
306 // Image assets
307 {
308 test: /\.(bmp|gif|jpe?g|png|svg|avif|webp)$/i,
309 type: 'asset',
310 parser: {
311 dataUrlCondition: {
312 maxSize: 8 * 1024, // 8KB
313 },
314 },
315 },
316 // Font assets
317 {
318 test: /\.(woff|woff2|otf|ttf|eot)$/i,
319 type: 'asset/resource',
320 },
321 ],
322 },
323
324 plugins: [
325 new rspack.HtmlRspackPlugin({
326 template: path.resolve(__dirname, 'web/index.html'),
327 inject: true,
328 }),
329 new rspack.CopyRspackPlugin({
330 patterns: [
331 // Serve fonts at /static/fonts/ with stable names
332 {from: 'web/static/fonts', to: 'static/fonts'},
333 // Serve the global stylesheet
334 {from: 'src/style.css', to: 'static/style.css'},
335 ],
336 }),
337 new rspack.DefinePlugin({
338 __DEV__: JSON.stringify(!isProduction),
339 'process.env.NODE_ENV': JSON.stringify(
340 isProduction ? 'production' : 'development',
341 ),
342 'process.env.JEST_WORKER_ID': JSON.stringify(undefined),
343 'process.env.LIVE_EVENTS_DEV_URL': JSON.stringify(
344 process.env.LIVE_EVENTS_DEV_URL || '',
345 ),
346 'process.env.APP_CONFIG_DEV_URL': JSON.stringify(
347 process.env.APP_CONFIG_DEV_URL || '',
348 ),
349 // provide sensible defaults for env vars that the web build expects but aren't defined in the environment
350 'process.env.EXPO_PUBLIC_ENV': JSON.stringify(
351 isProduction ? 'production' : 'development',
352 ),
353 'process.env.EAS_BUILD_PLATFORM': JSON.stringify('web'),
354 'process.env.SENTRY_AUTH_TOKEN': 'undefined',
355 'process.env.EXPO_PUBLIC_RELEASE_VERSION': 'undefined',
356 'process.env.EXPO_PUBLIC_LOG_LEVEL': '"debug"',
357 'process.env.EXPO_PUBLIC_LOG_DEBUG': '"*"',
358 'process.env.EXPO_PUBLIC_OAUTH_BASE_URL': 'undefined',
359 'process.env.EXPO_PUBLIC_OAUTH_CLIENT_NAME': 'undefined',
360 'process.env.EXPO_PUBLIC_BUNDLE_IDENTIFIER': 'undefined',
361 'process.env.EXPO_PUBLIC_BUNDLE_DATE': 'undefined',
362 'process.env.EXPO_PUBLIC_SENTRY_DSN': 'undefined',
363 'process.env.EXPO_PUBLIC_BLUESKY_PROXY_DID': 'undefined',
364 'process.env.EXPO_PUBLIC_CHAT_PROXY_DID': 'undefined',
365 'process.env.EXPO_PUBLIC_METRICS_API_HOST': 'undefined',
366 'process.env.EXPO_PUBLIC_GROWTHBOOK_API_HOST': 'undefined',
367 'process.env.EXPO_PUBLIC_GROWTHBOOK_CLIENT_KEY': 'undefined',
368 'process.env.EXPO_PUBLIC_BITDRIFT_API_KEY': 'undefined',
369 'process.env.EXPO_PUBLIC_GCP_PROJECT_ID': 'undefined',
370 'process.env.EXPO_PUBLIC_PUBLIC_BSKY_SERVICE': 'undefined',
371 'process.env.EXPO_PUBLIC_APPVIEW_DID_PROXY': 'undefined',
372 'process.env.APP_MANIFEST': 'undefined',
373 'process.env.__SENTRY_METRO_DEV_SERVER__': 'undefined',
374 'process.env.EXPO_OS': JSON.stringify('web'),
375 // Inject all EXPO_PUBLIC_* env vars
376 ...expoPublicEnv,
377 }),
378 // Generate asset-manifest.json matching the format the Go server expects.
379 // The post-web-build script reads `entrypoints` from this manifest.
380 new RspackManifestPlugin({
381 fileName: 'asset-manifest.json',
382 generate: (seed, files, entrypoints) => {
383 const entrypointFiles = entrypoints.main || []
384 return {
385 files: files.reduce((manifest, file) => {
386 manifest[file.name] = file.path
387 return manifest
388 }, seed),
389 entrypoints: entrypointFiles.filter(f => !f.endsWith('.map')),
390 }
391 },
392 }),
393 // Sentry source maps
394 isProduction &&
395 process.env.SENTRY_AUTH_TOKEN &&
396 sentryWebpackPlugin({
397 org: 'blueskyweb',
398 project: 'app',
399 authToken: process.env.SENTRY_AUTH_TOKEN,
400 release: {
401 name: process.env.SENTRY_RELEASE || version,
402 dist: process.env.SENTRY_DIST,
403 },
404 }),
405 ].filter(Boolean),
406
407 optimization: {
408 splitChunks: {
409 chunks: 'all',
410 cacheGroups: {
411 framework: {
412 test: /[\\/]node_modules[\\/](react|react-dom|react-native-web|@react-navigation|expo|@expo)[\\/]/,
413 name: 'framework',
414 chunks: 'initial',
415 priority: 20,
416 reuseExistingChunk: true,
417 },
418 vendor: {
419 test: /[\\/]node_modules[\\/]/,
420 name: 'vendor',
421 chunks: 'initial',
422 priority: -10,
423 reuseExistingChunk: true,
424 },
425 },
426 },
427 runtimeChunk: 'single',
428 minimize: isProduction,
429 },
430
431 devServer: {
432 static: {
433 directory: path.resolve(__dirname, 'web'),
434 },
435 port: 19006,
436 hot: true,
437 historyApiFallback: true,
438 compress: true,
439 },
440
441 // Don't bundle node built-ins (shouldn't be needed on web)
442 externalsPresets: {node: false},
443 node: {
444 __filename: false,
445 },
446
447 stats: GENERATE_STATS ? 'verbose' : 'normal',
448
449 experiments: {
450 css: true,
451 },
452}