next.config.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382
  1. const path = require('path'); // path import
  2. const webpack = require('webpack');
  3. const { withExpo } = require('@expo/next-adapter')
  4. const merge = require('deepmerge');
  5. const { ImageRemotePatterns } = require('app/settings/images-allowlist');
  6. // Import and run resource copying
  7. const fs = require('fs');
  8. // Resource copy helper
  9. function copyCustomizationResources() {
  10. const customizationRoot = path.resolve(__dirname, '../../packages/app/customization');
  11. const resourcesSource = path.resolve(customizationRoot, 'resources/web');
  12. const staticDest = path.resolve(__dirname, 'public/static');
  13. // Create static folder
  14. if (!fs.existsSync(staticDest)) {
  15. fs.mkdirSync(staticDest, { recursive: true });
  16. }
  17. if (fs.existsSync(resourcesSource)) {
  18. const files = fs.readdirSync(resourcesSource);
  19. let copiedCount = 0;
  20. files.forEach(file => {
  21. if (file.endsWith('.bak')) return;
  22. const sourcePath = path.join(resourcesSource, file);
  23. const stat = fs.statSync(sourcePath);
  24. if (stat.isFile()) {
  25. // Copy ONLY into static/
  26. fs.copyFileSync(sourcePath, path.join(staticDest, file));
  27. copiedCount++;
  28. }
  29. });
  30. }
  31. // Client projects reference /static/* in metadata; copy repo defaults when
  32. // customization/resources/web does not supply overrides.
  33. const staticDefaults = [
  34. { src: path.resolve(__dirname, 'public/manifest.json'), dest: 'manifest.json' },
  35. { src: path.resolve(__dirname, 'public/favicon.svg'), dest: 'favicon.svg' },
  36. ];
  37. staticDefaults.forEach(({ src, dest }) => {
  38. const destPath = path.join(staticDest, dest);
  39. if (!fs.existsSync(destPath) && fs.existsSync(src)) {
  40. fs.copyFileSync(src, destPath);
  41. }
  42. });
  43. }
  44. // Run copy BEFORE loading config
  45. copyCustomizationResources();
  46. // Static web icons: public/lucide/<strokeWidth>/<Name>.svg (see lib/generate-lucide-icons.js).
  47. require('./lib/generate-lucide-icons').generateLucideIcons();
  48. const nextConfigCustom = require('app/customization/config/next.config');
  49. /** @type {import('next').NextConfig} */
  50. // Monorepo path — check local node_modules first, then root
  51. const workspaceRoot = path.resolve(__dirname, '../..');
  52. const tenPlayWebviewLocalPath = path.resolve(__dirname, 'node_modules/@10play/react-native-web-webview');
  53. const tenPlayWebviewRootPath = path.resolve(workspaceRoot, 'node_modules/@10play/react-native-web-webview');
  54. const tenPlayWebviewPath = require('fs').existsSync(tenPlayWebviewLocalPath)
  55. ? path.resolve(tenPlayWebviewLocalPath, 'lib/module/index.js')
  56. : path.resolve(tenPlayWebviewRootPath, 'lib/module/index.js');
  57. const tenPlayWebviewShimPath = require('fs').existsSync(tenPlayWebviewLocalPath)
  58. ? path.resolve(tenPlayWebviewLocalPath, 'lib/module/shim.js')
  59. : path.resolve(tenPlayWebviewRootPath, 'lib/module/shim.js');
  60. const workspaceResolvePaths = [
  61. __dirname,
  62. workspaceRoot,
  63. path.resolve(__dirname, '../expo'),
  64. ];
  65. // Yarn may hoist these to root, apps/next, apps/expo, or nest them under the
  66. // parent package. Vercel clean installs do not match a local Windows layout.
  67. function resolveFromPackage(pkg, subpath) {
  68. const pkgRoots = workspaceResolvePaths.map((base) =>
  69. path.resolve(base, 'node_modules', pkg)
  70. );
  71. for (const root of pkgRoots) {
  72. const full = path.join(root, subpath);
  73. if (fs.existsSync(full)) return full;
  74. }
  75. try {
  76. const pkgJson = require.resolve(`${pkg}/package.json`, {
  77. paths: workspaceResolvePaths,
  78. });
  79. const full = path.join(path.dirname(pkgJson), subpath);
  80. if (fs.existsSync(full)) return full;
  81. } catch {
  82. // fall through
  83. }
  84. return path.join(pkgRoots[1], subpath);
  85. }
  86. function replaceEnrichedHtmlHook(fileStem, shimFile) {
  87. return new webpack.NormalModuleReplacementPlugin(
  88. new RegExp(`${fileStem}(?:\\.(?:js|ts))?$`),
  89. (resource) => {
  90. const ctx = resource.context || '';
  91. const req = String(resource.request || '');
  92. if (!ctx.includes('react-native-enriched-html')) return;
  93. if (ctx.includes(`${path.sep}shims`) || ctx.includes('/shims/')) return;
  94. if (req.includes('-real') || req.includes('enriched-use-')) return;
  95. resource.request = path.resolve(__dirname, 'shims', shimFile);
  96. }
  97. );
  98. }
  99. // Реальный @tiptap/react (используется веб-сборкой react-native-enriched-html).
  100. // Подключается отдельным alias, чтобы шим '@tiptap/react' не вызывал сам себя.
  101. const tiptapReactRealPath = resolveFromPackage('@tiptap/react', 'dist/index.js');
  102. const enrichedUseOnEditorChangeRealPath = resolveFromPackage(
  103. 'react-native-enriched-html',
  104. 'lib/module/web/useOnEditorChange.js'
  105. );
  106. const enrichedUseMentionEventsRealPath = resolveFromPackage(
  107. 'react-native-enriched-html',
  108. 'lib/module/web/pmPlugins/MentionPlugin/useMentionEvents.js'
  109. );
  110. const md4cEmscriptenLocalPath = path.resolve(
  111. __dirname,
  112. 'node_modules/react-native-enriched-markdown/lib/module/web/wasm/md4c.js'
  113. );
  114. const md4cEmscriptenRootPath = path.resolve(
  115. workspaceRoot,
  116. 'node_modules/react-native-enriched-markdown/lib/module/web/wasm/md4c.js'
  117. );
  118. const md4cEmscriptenSourcePath = fs.existsSync(md4cEmscriptenLocalPath)
  119. ? md4cEmscriptenLocalPath
  120. : md4cEmscriptenRootPath;
  121. // md4c.js — UMD/CJS Emscripten-бандл, но lib/module/package.json пакета содержит
  122. // {"type":"module"}, из-за чего webpack парсит его как ESM и теряет module.exports.
  123. // Копируем в .cjs вне scope пакета — webpack всегда собирает .cjs как CommonJS.
  124. const md4cEmscriptenRealPath = path.resolve(__dirname, 'shims/md4c-emscripten.cjs');
  125. if (fs.existsSync(md4cEmscriptenSourcePath)) {
  126. const srcStat = fs.statSync(md4cEmscriptenSourcePath);
  127. const needCopy =
  128. !fs.existsSync(md4cEmscriptenRealPath) ||
  129. fs.statSync(md4cEmscriptenRealPath).size !== srcStat.size;
  130. if (needCopy) {
  131. fs.copyFileSync(md4cEmscriptenSourcePath, md4cEmscriptenRealPath);
  132. }
  133. }
  134. const allowLocalImageIPs = ['1', 'true'].includes(process.env.NEO_IMAGES_ALLOW_LOCAL_IP);
  135. if (allowLocalImageIPs) {
  136. console.warn('NEO_IMAGES_ALLOW_LOCAL_IP is on: next/image fetches from private IPs (SSRF risk). Local development only.');
  137. }
  138. const nextConfig = {
  139. typescript: {
  140. ignoreBuildErrors: true,
  141. },
  142. // React Compiler (client bundle only). Components that violate the Rules of
  143. // React are skipped, not broken — `yarn lint` (react-hooks/*) lists them.
  144. // Opt a file/component out with the 'use no memo' directive. Forks can
  145. // disable it via customization/config/next.config.js (`reactCompiler: false`).
  146. reactCompiler: true,
  147. // Bridge non-public GOOGLE_WEB_CLIENT_ID into the client bundle so SSR and
  148. // hydration agree on whether Google auth (and the OR separator) render.
  149. // Prefer NEXT_PUBLIC_* when set; fall back to the server-only name used in .env.
  150. env: {
  151. NEXT_PUBLIC_GOOGLE_WEB_CLIENT_ID:
  152. process.env.NEXT_PUBLIC_GOOGLE_WEB_CLIENT_ID ||
  153. process.env.GOOGLE_WEB_CLIENT_ID ||
  154. '',
  155. // Preview backends (see UNA_PREVIEW_DOMAIN in proxy.js); the browser needs it for
  156. // the image allowlist. Only the domain, never the key.
  157. NEXT_PUBLIC_UNA_PREVIEW_DOMAIN:
  158. process.env.NEXT_PUBLIC_UNA_PREVIEW_DOMAIN ||
  159. process.env.UNA_PREVIEW_DOMAIN ||
  160. '',
  161. },
  162. // Disable source maps in production
  163. productionBrowserSourceMaps: false,
  164. /*experimental: {
  165. ppr: true,
  166. },*/
  167. experimental: {
  168. // Short client RSC cache so pointer-down prefetch of dynamic pages
  169. // (profiles) is reused on click. 0 made every tap wait on UNA again.
  170. staleTimes: {
  171. dynamic: 20,
  172. static: 180,
  173. },
  174. },
  175. // reanimated (and thus, Moti) doesn't work with strict mode currently...
  176. // https://github.com/nandorojo/moti/issues/224
  177. // https://github.com/necolas/react-native-web/pull/2330
  178. // https://github.com/nandorojo/moti/issues/224
  179. // once that gets fixed, set this back to true
  180. reactStrictMode: false,
  181. poweredByHeader: false,
  182. async rewrites() {
  183. return {
  184. // Only when no public/lucide file matches: stroke widths that were not
  185. // generated, or Lucide names newer than the generated set. afterFiles,
  186. // not fallback: fallback runs after dynamic routes, and [...path] would
  187. // take the request first.
  188. afterFiles: [
  189. {
  190. source: '/lucide/:strokeWidth(\\d+)/:icon([A-Z][A-Za-z0-9]*).svg',
  191. destination: '/api/icon?icon=:icon&strokeWidth=:strokeWidth',
  192. },
  193. ],
  194. };
  195. },
  196. async headers() {
  197. return [
  198. {
  199. // File names are not versioned, so a Lucide upgrade must reach browsers.
  200. source: '/lucide/:path*',
  201. headers: [{ key: 'Cache-Control', value: 'public, max-age=604800, stale-while-revalidate=86400' }],
  202. },
  203. ];
  204. },
  205. allowedDevOrigins: ['yasko.local', 'neo.localhost', 'bs-local.com'],
  206. /*experimental: {
  207. forceSwcTransforms: true,
  208. // scrollRestoration: true,
  209. swcPlugins: [[require.resolve('./plugins/swc_plugin_reanimated.wasm')]],
  210. },*/
  211. transpilePackages: [
  212. 'react-native',
  213. 'react-native-web',
  214. 'solito',
  215. '@expo/html-elements',
  216. 'react-native-gesture-handler',
  217. '@react-native-clipboard/clipboard',
  218. '@babel/core',
  219. 'expo-router',
  220. 'react-native-calendars',
  221. 'react-native-image-pan-zoom',
  222. 'react-native-swipe-gestures',
  223. 'react-native-toast-message',
  224. 'expo-haptics',
  225. 'expo-modules-core',
  226. 'recyclerlistview',
  227. 'expo-crypto',
  228. 'jotai',
  229. '@react-native-picker/picker',
  230. '@10play/tentap-editor',
  231. '@10play/react-native-web-webview',
  232. '@appandflow/react-native-google-autocomplete',
  233. 'react-native-webview',
  234. 'expo',
  235. 'expo-audio',
  236. 'expo-image-picker',
  237. 'expo-location',
  238. 'expo-camera',
  239. 'expo-document-picker',
  240. 'expo-image-manipulator',
  241. 'react-native-enriched-markdown',
  242. '@mdxeditor/editor',
  243. 'expo-constants',
  244. "react-native-svg",
  245. '@expo/metro-runtime',
  246. 'i18next',
  247. 'react-i18next',
  248. 'react-native-enriched-html',
  249. 'react-native-localize',
  250. 'victory-native',
  251. '@stripe/stripe-react-native',
  252. 'react-native-star-rating-widget',
  253. '@openspacelabs/react-native-zoomable-view',
  254. 'lucide-react-native',
  255. 'web-haptics',
  256. '@tanstack/ai-react',
  257. '@tanstack/ai-client',
  258. '@tanstack/ai',
  259. ],
  260. webpack: (config, { isServer }) => {
  261. // Add aliases
  262. config.resolve.alias = {
  263. ...config.resolve.alias,
  264. 'react-native': 'react-native-web',
  265. 'react-native-webview': tenPlayWebviewPath,
  266. 'react-native-webview$': tenPlayWebviewPath,
  267. 'jotai': path.resolve(workspaceRoot, 'node_modules/jotai'),
  268. 'crypto': 'expo-crypto',
  269. 'react-native-svg': path.resolve(__dirname, 'node_modules/react-native-svg'),
  270. 'react-native/Libraries/Utilities/codegenNativeComponent': tenPlayWebviewShimPath,
  271. 'react-native/Libraries/Utilities/codegenNativeComponent$': tenPlayWebviewShimPath,
  272. 'react-native-localize': path.resolve(__dirname, 'stubs/react-native-localize.js'),
  273. // Native-only. NeoButton web uses neo-button-expoui.web.ts; this is a
  274. // backstop if webpack ever follows .ios.js / .android.js.
  275. '@expo/ui': path.resolve(__dirname, 'stubs/expo-ui.js'),
  276. '@expo/ui/swift-ui': path.resolve(__dirname, 'stubs/expo-ui.js'),
  277. '@expo/ui/swift-ui/modifiers': path.resolve(__dirname, 'stubs/expo-ui.js'),
  278. '@expo/ui/jetpack-compose': path.resolve(__dirname, 'stubs/expo-ui.js'),
  279. '@expo/ui/jetpack-compose/modifiers': path.resolve(__dirname, 'stubs/expo-ui.js'),
  280. // Shim forces immediatelyRender: false (see shims/tiptap-react.js).
  281. // '$' — точное совпадение, поэтому '@tiptap/react/menus' и пр. не трогаем.
  282. '@tiptap/react$': path.resolve(__dirname, 'shims/tiptap-react.js'),
  283. '@tiptap-react-real$': tiptapReactRealPath,
  284. '@enriched-use-on-editor-change-real$': enrichedUseOnEditorChangeRealPath,
  285. '@enriched-use-mention-events-real$': enrichedUseMentionEventsRealPath,
  286. 'md4c-emscripten-real$': md4cEmscriptenRealPath,
  287. };
  288. // Add fallback for codegenNativeComponent
  289. config.resolve.fallback = {
  290. ...config.resolve.fallback,
  291. 'react-native/Libraries/Utilities/codegenNativeComponent': tenPlayWebviewShimPath,
  292. };
  293. // Local node_modules first so packages installed under apps/next
  294. // (e.g. react-native-svg) win over root-hoisted copies.
  295. config.resolve.modules = [
  296. path.resolve(__dirname, 'node_modules'),
  297. ...(config.resolve.modules || []).filter(m =>
  298. m !== path.resolve(__dirname, 'node_modules') &&
  299. m !== path.resolve(workspaceRoot, 'node_modules')
  300. ),
  301. path.resolve(workspaceRoot, 'node_modules'),
  302. ];
  303. // Force module replacement via NormalModuleReplacementPlugin
  304. config.plugins = config.plugins || [];
  305. config.plugins.push(
  306. new webpack.DefinePlugin({
  307. 'process.env.EXPO_OS': JSON.stringify('web'),
  308. }),
  309. new webpack.NormalModuleReplacementPlugin(
  310. /^react-native-webview$/,
  311. (resource) => {
  312. // Replace with web build for all imports (especially from @10play/tentap-editor)
  313. resource.request = tenPlayWebviewPath;
  314. }
  315. ),
  316. // Fix Emscripten CJS default export for react-native-enriched-markdown WASM parser.
  317. // parseMarkdown uses dynamic import('./wasm/md4c') — request is relative, not full path.
  318. new webpack.NormalModuleReplacementPlugin(
  319. /^\.\/wasm\/md4c$/,
  320. path.resolve(__dirname, 'shims/md4c.js')
  321. ),
  322. new webpack.NormalModuleReplacementPlugin(
  323. /^@expo\/ui(\/.*)?$/,
  324. path.resolve(__dirname, 'stubs/expo-ui.js')
  325. ),
  326. // Null-guard TipTap hooks (editor is null on the first Next.js frame).
  327. // In-repo shims so a clean Vercel install does not need patch-package.
  328. replaceEnrichedHtmlHook('useOnEditorChange', 'enriched-use-on-editor-change.js'),
  329. replaceEnrichedHtmlHook('useMentionEvents', 'enriched-use-mention-events.js')
  330. );
  331. config.module.rules.push({
  332. test: /\.(mp3|wav)$/,
  333. type: 'asset/resource',
  334. });
  335. return config;
  336. },
  337. images: {
  338. remotePatterns: ImageRemotePatterns,
  339. disableStaticImages: false,
  340. // Keep optimized variants warm so browse grids don't re-hit the optimizer on every visit.
  341. minimumCacheTTL: 60 * 60 * 24,
  342. // Card thumbnails never need 3840w; dropping the top sizes avoids expensive optimizer failures.
  343. deviceSizes: [640, 750, 828, 1080, 1200, 1920],
  344. imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
  345. // Local development against a UNA on localhost or a LAN address. Next 16 refuses
  346. // upstream images that resolve to a private IP (SSRF protection); never set in production.
  347. dangerouslyAllowLocalIP: allowLocalImageIPs,
  348. },
  349. }
  350. const withBundleAnalyzer = require('@next/bundle-analyzer')({
  351. enabled: process.env.ANALYZE === 'true',
  352. })
  353. module.exports = withExpo(withBundleAnalyzer(
  354. merge(nextConfig, nextConfigCustom)
  355. ))