next.config.js 17 KB

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