free-coding-models 0.5.86 β†’ 0.5.88

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/README.md +109 -888
  2. package/bin/free-coding-models.js +4 -1
  3. package/changelog/v0.5.87.md +48 -0
  4. package/changelog/v0.5.88.md +16 -0
  5. package/package.json +13 -5
  6. package/sources.js +4 -4
  7. package/src/core/cache.js +3 -1
  8. package/src/core/config.js +43 -5
  9. package/src/core/endpoint-installer.js +23 -12
  10. package/src/core/installed-models-manager.js +43 -20
  11. package/src/core/kilo-config.js +51 -5
  12. package/src/core/kilo.js +5 -3
  13. package/src/core/legacy-proxy-cleanup.js +22 -5
  14. package/src/core/model-merger.js +4 -0
  15. package/src/core/models-drift.js +9 -0
  16. package/src/core/opencode-config.js +57 -5
  17. package/src/core/opencode.js +67 -41
  18. package/src/core/playground.js +7 -0
  19. package/src/core/probe-cache.js +35 -4
  20. package/src/core/provider-key-tester.js +10 -2
  21. package/src/core/provider-metadata.js +10 -0
  22. package/src/core/provider-quota-fetchers.js +14 -7
  23. package/src/core/router-daemon.js +208 -26
  24. package/src/core/runtime-telemetry.js +27 -1
  25. package/src/core/security.js +170 -24
  26. package/src/core/shared-helpers.js +12 -1
  27. package/src/core/shell-env.js +3 -1
  28. package/src/core/telemetry.js +18 -5
  29. package/src/core/tool-launchers.js +9 -6
  30. package/src/core/updater.js +28 -2
  31. package/src/core/utils.js +173 -71
  32. package/src/tui/app.js +38 -14
  33. package/src/tui/command-palette.js +22 -2
  34. package/src/tui/key-handler.js +123 -179
  35. package/src/tui/overlays.js +21 -71
  36. package/src/tui/render-helpers.js +62 -4
  37. package/src/tui/render-table.js +34 -3
  38. package/src/tui/theme.js +36 -5
  39. package/src/tui/tui-filters.js +28 -8
  40. package/src/tui/tui-state.js +22 -6
  41. package/web/dist/assets/index-CAzFIt8P.css +1 -0
  42. package/web/dist/assets/index-DFg1h0Nd.js +44 -0
  43. package/web/dist/index.html +5 -9
  44. package/web/index.html +3 -7
  45. package/web/server.js +97 -23
  46. package/web/vite.config.js +8 -0
  47. package/web/app.legacy.js +0 -900
  48. package/web/dist/assets/index-C5-Ywvv3.css +0 -1
  49. package/web/dist/assets/index-Dm-Xfby9.js +0 -40
  50. package/web/public/favicon.ico +0 -0
  51. package/web/public/favicons/apple-touch-icon.png +0 -0
  52. package/web/public/favicons/browserconfig.xml +0 -12
  53. package/web/public/favicons/favicon-16x16.png +0 -0
  54. package/web/public/favicons/favicon-192x192.png +0 -0
  55. package/web/public/favicons/favicon-32x32.png +0 -0
  56. package/web/public/favicons/favicon-48x48.png +0 -0
  57. package/web/public/favicons/favicon-512x512.png +0 -0
  58. package/web/public/favicons/favicon-96x96.png +0 -0
  59. package/web/public/favicons/favicon.ico +0 -0
  60. package/web/public/favicons/mstile-150x150.png +0 -0
  61. package/web/public/favicons/mstile-310x150.png +0 -0
  62. package/web/public/favicons/mstile-310x310.png +0 -0
  63. package/web/public/favicons/mstile-512x512.png +0 -0
  64. package/web/public/favicons/mstile-70x70.png +0 -0
  65. package/web/public/favicons/site.webmanifest +0 -25
  66. package/web/src/App.jsx +0 -521
  67. package/web/src/components/analytics/AnalyticsView.jsx +0 -147
  68. package/web/src/components/analytics/AnalyticsView.module.css +0 -190
  69. package/web/src/components/analytics/TokenUsagePanel.jsx +0 -105
  70. package/web/src/components/analytics/TokenUsagePanel.module.css +0 -126
  71. package/web/src/components/atoms/AILatencyCell.jsx +0 -38
  72. package/web/src/components/atoms/AILatencyCell.module.css +0 -51
  73. package/web/src/components/atoms/HealthCell.jsx +0 -143
  74. package/web/src/components/atoms/HealthCell.module.css +0 -60
  75. package/web/src/components/atoms/LastPingCell.jsx +0 -35
  76. package/web/src/components/atoms/LastPingCell.module.css +0 -45
  77. package/web/src/components/atoms/MoodCell.jsx +0 -25
  78. package/web/src/components/atoms/MoodCell.module.css +0 -6
  79. package/web/src/components/atoms/NoKeyIcon.jsx +0 -65
  80. package/web/src/components/atoms/NoKeyIcon.module.css +0 -30
  81. package/web/src/components/atoms/ProviderLogo.jsx +0 -187
  82. package/web/src/components/atoms/ProviderLogo.module.css +0 -103
  83. package/web/src/components/atoms/RankCell.jsx +0 -9
  84. package/web/src/components/atoms/RankCell.module.css +0 -9
  85. package/web/src/components/atoms/Sparkline.jsx +0 -63
  86. package/web/src/components/atoms/StabilityCell.jsx +0 -18
  87. package/web/src/components/atoms/StabilityCell.module.css +0 -13
  88. package/web/src/components/atoms/StatusDot.jsx +0 -13
  89. package/web/src/components/atoms/StatusDot.module.css +0 -29
  90. package/web/src/components/atoms/TPSCell.jsx +0 -36
  91. package/web/src/components/atoms/TPSCell.module.css +0 -44
  92. package/web/src/components/atoms/TierBadge.jsx +0 -10
  93. package/web/src/components/atoms/TierBadge.module.css +0 -29
  94. package/web/src/components/atoms/Toast.jsx +0 -25
  95. package/web/src/components/atoms/Toast.module.css +0 -41
  96. package/web/src/components/atoms/ToastContainer.jsx +0 -16
  97. package/web/src/components/atoms/ToastContainer.module.css +0 -10
  98. package/web/src/components/atoms/VerdictBadge.jsx +0 -36
  99. package/web/src/components/atoms/VerdictBadge.module.css +0 -40
  100. package/web/src/components/changelog/ChangelogView.jsx +0 -135
  101. package/web/src/components/changelog/ChangelogView.module.css +0 -159
  102. package/web/src/components/dashboard/DetailPanel.jsx +0 -217
  103. package/web/src/components/dashboard/DetailPanel.module.css +0 -260
  104. package/web/src/components/dashboard/ExpandedDetailRow.jsx +0 -361
  105. package/web/src/components/dashboard/ExpandedDetailRow.module.css +0 -356
  106. package/web/src/components/dashboard/ExportModal.jsx +0 -84
  107. package/web/src/components/dashboard/ExportModal.module.css +0 -99
  108. package/web/src/components/dashboard/FilterBar.jsx +0 -338
  109. package/web/src/components/dashboard/FilterBar.module.css +0 -342
  110. package/web/src/components/dashboard/ModelTable.jsx +0 -672
  111. package/web/src/components/dashboard/ModelTable.module.css +0 -446
  112. package/web/src/components/dashboard/ProviderDropdown.jsx +0 -156
  113. package/web/src/components/dashboard/ProviderDropdown.module.css +0 -248
  114. package/web/src/components/help/HelpView.jsx +0 -201
  115. package/web/src/components/help/HelpView.module.css +0 -156
  116. package/web/src/components/install/InstallEndpointsView.jsx +0 -309
  117. package/web/src/components/install/InstallEndpointsView.module.css +0 -427
  118. package/web/src/components/installed/InstalledModelsView.jsx +0 -89
  119. package/web/src/components/installed/InstalledModelsView.module.css +0 -200
  120. package/web/src/components/launch/IncompatibleFallbackModal.jsx +0 -69
  121. package/web/src/components/launch/LaunchButton.jsx +0 -30
  122. package/web/src/components/launch/LaunchButton.module.css +0 -35
  123. package/web/src/components/launch/LaunchModal.module.css +0 -125
  124. package/web/src/components/layout/Footer.jsx +0 -19
  125. package/web/src/components/layout/Footer.module.css +0 -10
  126. package/web/src/components/layout/Header.jsx +0 -265
  127. package/web/src/components/layout/Header.module.css +0 -357
  128. package/web/src/components/palette/CommandPalette.jsx +0 -301
  129. package/web/src/components/palette/CommandPalette.module.css +0 -121
  130. package/web/src/components/playground/PlaygroundChat.jsx +0 -527
  131. package/web/src/components/playground/PlaygroundChat.module.css +0 -382
  132. package/web/src/components/playground/PlaygroundView.jsx +0 -371
  133. package/web/src/components/playground/PlaygroundView.module.css +0 -504
  134. package/web/src/components/recommend/RecommendView.jsx +0 -121
  135. package/web/src/components/recommend/RecommendView.module.css +0 -57
  136. package/web/src/components/router/RouterView.jsx +0 -1212
  137. package/web/src/components/router/RouterView.module.css +0 -1289
  138. package/web/src/components/settings/SettingsView.jsx +0 -555
  139. package/web/src/components/settings/SettingsView.module.css +0 -550
  140. package/web/src/components/tools/ToolPicker.jsx +0 -86
  141. package/web/src/components/tools/ToolPicker.module.css +0 -97
  142. package/web/src/components/update/UpdateChip.jsx +0 -104
  143. package/web/src/components/update/UpdateChip.module.css +0 -146
  144. package/web/src/global.css +0 -298
  145. package/web/src/hooks/urlState.constants.js +0 -28
  146. package/web/src/hooks/useChangelog.js +0 -51
  147. package/web/src/hooks/useColumnSizing.js +0 -211
  148. package/web/src/hooks/useFavorites.js +0 -150
  149. package/web/src/hooks/useFilter.js +0 -310
  150. package/web/src/hooks/useInstalledModels.js +0 -42
  151. package/web/src/hooks/useRecommend.js +0 -67
  152. package/web/src/hooks/useRouterDashboard.js +0 -133
  153. package/web/src/hooks/useSSE.js +0 -73
  154. package/web/src/hooks/useSocket.js +0 -203
  155. package/web/src/hooks/useTheme.js +0 -83
  156. package/web/src/hooks/useTokenUsage.js +0 -103
  157. package/web/src/hooks/useToolMode.js +0 -77
  158. package/web/src/hooks/useUpdateChecker.js +0 -91
  159. package/web/src/hooks/useUrlState.js +0 -170
  160. package/web/src/main.jsx +0 -15
  161. package/web/src/utils/download.js +0 -15
  162. package/web/src/utils/format.js +0 -42
  163. package/web/src/utils/m3.js +0 -57
  164. package/web/src/utils/ranks.js +0 -37
  165. package/web/styles.legacy.css +0 -963
@@ -45,17 +45,13 @@
45
45
  <meta name="twitter:description" content="Find the fastest free coding LLM model in seconds.">
46
46
  <meta name="twitter:image" content="/favicons/favicon-512x512.png">
47
47
 
48
- <link rel="preconnect" href="https://fonts.googleapis.com">
49
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
50
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
51
- <script type="module" crossorigin src="/assets/index-Dm-Xfby9.js"></script>
52
- <link rel="stylesheet" crossorigin href="/assets/index-C5-Ywvv3.css">
48
+ <!-- πŸ“– No webfont links: the dashboard must work fully offline / air-gapped.
49
+ πŸ“– Font stacks live in web/src/global.css and fall back to system fonts
50
+ πŸ“– (locally installed Inter / JetBrains Mono are still picked up if present). -->
51
+ <script type="module" crossorigin src="/assets/index-DFg1h0Nd.js"></script>
52
+ <link rel="stylesheet" crossorigin href="/assets/index-CAzFIt8P.css">
53
53
  </head>
54
54
  <body>
55
55
  <div id="root"></div>
56
- <!-- πŸ“– PostHog analytics β€” privacy-first, self-hosted option available -->
57
- <script>
58
- !function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host.replace(".i.",".js."),function(t,e,o,n){var i,s;for(n=function(t,e){return t[e]},s=e.split("."),i=0;i<s.length-1;i++)n=n(s[i],t);(a=n[s.pop()])&&a.apply(t,[].slice.call(arguments,0))}(e,"5.0.0")},e.init('phc_PLACEHOLDER_REPLACE_WITH_YOUR_KEY',{api_host:'https://us.i.posthog.com'}))}(document,window.posthog||[]);
59
- </script>
60
56
  </body>
61
57
  </html>
package/web/index.html CHANGED
@@ -45,16 +45,12 @@
45
45
  <meta name="twitter:description" content="Find the fastest free coding LLM model in seconds.">
46
46
  <meta name="twitter:image" content="/favicons/favicon-512x512.png">
47
47
 
48
- <link rel="preconnect" href="https://fonts.googleapis.com">
49
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
50
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
48
+ <!-- πŸ“– No webfont links: the dashboard must work fully offline / air-gapped.
49
+ πŸ“– Font stacks live in web/src/global.css and fall back to system fonts
50
+ πŸ“– (locally installed Inter / JetBrains Mono are still picked up if present). -->
51
51
  </head>
52
52
  <body>
53
53
  <div id="root"></div>
54
54
  <script type="module" src="/src/main.jsx"></script>
55
- <!-- πŸ“– PostHog analytics β€” privacy-first, self-hosted option available -->
56
- <script>
57
- !function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host.replace(".i.",".js."),function(t,e,o,n){var i,s;for(n=function(t,e){return t[e]},s=e.split("."),i=0;i<s.length-1;i++)n=n(s[i],t);(a=n[s.pop()])&&a.apply(t,[].slice.call(arguments,0))}(e,"5.0.0")},e.init('phc_PLACEHOLDER_REPLACE_WITH_YOUR_KEY',{api_host:'https://us.i.posthog.com'}))}(document,window.posthog||[]);
58
- </script>
59
55
  </body>
60
56
  </html>
package/web/server.js CHANGED
@@ -696,11 +696,67 @@ function buildRecommendReason(result, answers) {
696
696
  return `${bits.join(' Β· ') || 'Strong catalog fit'} for ${priority.toLowerCase()} priority.`
697
697
  }
698
698
 
699
+ // ─── Same-origin guard (mirrors src/core/router-daemon.js) ──────────────────
700
+
701
+ // πŸ“– Loopback hostnames are always trusted: the dashboard is a local tool.
702
+ function isLoopbackHostname(hostname) {
703
+ if (!hostname) return false
704
+ const h = hostname.toLowerCase()
705
+ return h === 'localhost' || h === '127.0.0.1' || h === '[::1]' || h === '::1' || h.endsWith('.localhost')
706
+ }
707
+
708
+ // πŸ“– FCM_ALLOWED_ORIGINS: comma-separated extra origins allowed to call the
709
+ // πŸ“– dashboard, e.g. "http://mybox:3333,http://10.0.0.5:3333". Used for LAN
710
+ // πŸ“– setups when the server is bound to a non-loopback FCM_HOST.
711
+ function getAllowedOrigins() {
712
+ return (process.env.FCM_ALLOWED_ORIGINS || '')
713
+ .split(',')
714
+ .map((s) => s.trim())
715
+ .filter(Boolean)
716
+ }
717
+
718
+ // πŸ“– Origin guard applied to every route: only loopback origins, requests
719
+ // πŸ“– whose Origin matches the Host the browser is already talking to, or
720
+ // πŸ“– explicitly allowed origins may call the dashboard. Non-browser clients
721
+ // πŸ“– (curl, TUI, proxies) send no Origin/Referer and pass. An `Origin: null`
722
+ // πŸ“– (sandboxed iframe) is rejected unless a loopback Referer accompanies it.
723
+ function isRequestOriginAllowed(req) {
724
+ const origin = req.headers.origin
725
+ const referer = req.headers.referer || req.headers.referrer
726
+ const hasOrigin = typeof origin === 'string' && origin.length > 0
727
+ const candidates = []
728
+ if (hasOrigin && origin !== 'null') {
729
+ candidates.push(origin)
730
+ } else if (hasOrigin && origin === 'null') {
731
+ if (typeof referer !== 'string' || !referer) return false
732
+ candidates.push(referer)
733
+ } else if (typeof referer === 'string' && referer) {
734
+ candidates.push(referer)
735
+ }
736
+ if (candidates.length === 0) return true
737
+
738
+ const hostHeader = typeof req.headers.host === 'string' ? req.headers.host.toLowerCase() : ''
739
+ for (const c of candidates) {
740
+ try {
741
+ const parsed = new URL(c)
742
+ if (isLoopbackHostname(parsed.hostname)) return true
743
+ if (hostHeader && parsed.host.toLowerCase() === hostHeader) return true
744
+ if (getAllowedOrigins().includes(c)) return true
745
+ } catch {
746
+ return false
747
+ }
748
+ }
749
+ return false
750
+ }
751
+
699
752
  async function handleRequest(req, res) {
753
+ // πŸ“– Same-origin guard first: never let a hostile page read this server.
754
+ if (!isRequestOriginAllowed(req)) {
755
+ res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' })
756
+ res.end('Forbidden cross-origin request')
757
+ return
758
+ }
700
759
  res.setHeader('X-FCM-Server', SERVER_SIGNATURE)
701
- res.setHeader('Access-Control-Allow-Origin', '*')
702
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
703
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
704
760
 
705
761
  if (req.method === 'OPTIONS') {
706
762
  res.writeHead(204)
@@ -738,9 +794,10 @@ async function handleRequest(req, res) {
738
794
  return
739
795
  }
740
796
 
741
- // πŸ“– Single-provider key reveal endpoint. The M2 /api/key/:provider/test
742
- // πŸ“– route is matched above (above the switch) and uses a stricter regex
743
- // πŸ“– (one segment, not two) so the two routes coexist cleanly.
797
+ // πŸ“– Single-provider key endpoint. Returns the MASKED key plus a hasKey
798
+ // πŸ“– flag only: the raw key never leaves the config file over HTTP. The
799
+ // πŸ“– M2 /api/key/:provider/test route is matched above and uses a stricter
800
+ // πŸ“– regex (one segment + /test) so the two routes coexist cleanly.
744
801
  const keyMatch = url.pathname.match(/^\/api\/key\/([^/]+)$/)
745
802
  if (keyMatch) {
746
803
  const providerKey = decodeURIComponent(keyMatch[1])
@@ -748,7 +805,8 @@ async function handleRequest(req, res) {
748
805
  sendJson(res, 404, { error: 'Unknown provider' })
749
806
  return
750
807
  }
751
- sendJson(res, 200, { key: getApiKey(config, providerKey) || null })
808
+ const rawKey = getApiKey(config, providerKey)
809
+ sendJson(res, 200, { key: rawKey ? maskApiKey(rawKey) : null, hasKey: Boolean(rawKey) })
752
810
  return
753
811
  }
754
812
 
@@ -1886,12 +1944,17 @@ async function handleRequest(req, res) {
1886
1944
  }
1887
1945
 
1888
1946
  function checkPortInUse(port) {
1889
- return new Promise((resolve) => {
1947
+ // πŸ“– Probe both the wildcard and the loopback address: macOS SO_REUSEADDR
1948
+ // πŸ“– lets a wildcard probe succeed even when 127.0.0.1:port is taken (the
1949
+ // πŸ“– dashboard binds loopback by default), and a loopback probe succeed
1950
+ // πŸ“– when another app owns the wildcard bind. Either hit means "in use".
1951
+ const probe = (host) => new Promise((resolve) => {
1890
1952
  const s = createServer()
1891
1953
  s.once('error', (err) => { if (err.code === 'EADDRINUSE') resolve(true); else resolve(false) })
1892
1954
  s.once('listening', () => { s.close(); resolve(false) })
1893
- s.listen(port)
1955
+ s.listen(port, host)
1894
1956
  })
1957
+ return Promise.all([probe(undefined), probe('127.0.0.1')]).then(([wildcard, loopback]) => wildcard || loopback)
1895
1958
  }
1896
1959
 
1897
1960
  export async function inspectExistingWebServer(port) {
@@ -1982,20 +2045,31 @@ export async function startWebServer(port = DEFAULT_WEB_PORT, { open = true, sta
1982
2045
  socket.on('models:refresh', () => socket.emit('models:update', getModelsPayload()))
1983
2046
  })
1984
2047
 
1985
- server.listen(resolvedPort, () => {
1986
- console.log()
1987
- console.log(' ⚑ free-coding-models Web Dashboard')
1988
- console.log(` 🌐 ${url}`)
1989
- console.log(` πŸ“Š Monitoring ${results.filter((r) => !r.cliOnly).length} models across ${Object.keys(sources).length} providers`)
1990
- console.log()
1991
- console.log(' Press Ctrl+C to stop')
1992
- console.log()
1993
- if (startPingLoop && !pingLoopTimer) {
1994
- runtime.lastPingTime = Date.now()
1995
- runtime.nextPingAt = runtime.lastPingTime + runtime.activePingInterval
1996
- startPingCycle()
1997
- }
1998
- if (open) openBrowser(url)
2048
+ // πŸ“– Bind loopback by default so the dashboard is never reachable from the
2049
+ // πŸ“– LAN by accident; set FCM_HOST (e.g. "0.0.0.0" for Docker) to override.
2050
+ // πŸ“– Await the actual bind so callers can hit the port as soon as this
2051
+ // πŸ“– resolves (tests read server.address() right after startup).
2052
+ const bindHost = process.env.FCM_HOST || '127.0.0.1'
2053
+ await new Promise((resolve, reject) => {
2054
+ const onError = (err) => reject(err)
2055
+ server.once('error', onError)
2056
+ server.listen(resolvedPort, bindHost, () => {
2057
+ server.off('error', onError)
2058
+ console.log()
2059
+ console.log(' ⚑ free-coding-models Web Dashboard')
2060
+ console.log(` 🌐 ${url}`)
2061
+ console.log(` πŸ“Š Monitoring ${results.filter((r) => !r.cliOnly).length} models across ${Object.keys(sources).length} providers`)
2062
+ console.log()
2063
+ console.log(' Press Ctrl+C to stop')
2064
+ console.log()
2065
+ if (startPingLoop && !pingLoopTimer) {
2066
+ runtime.lastPingTime = Date.now()
2067
+ runtime.nextPingAt = runtime.lastPingTime + runtime.activePingInterval
2068
+ startPingCycle()
2069
+ }
2070
+ if (open) openBrowser(url)
2071
+ resolve()
2072
+ })
1999
2073
  })
2000
2074
 
2001
2075
  server.on('close', () => {
@@ -13,6 +13,14 @@ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url),
13
13
  export default defineConfig({
14
14
  plugins: [react()],
15
15
  root: 'web',
16
+ // πŸ“– Force a single React instance across the app + every dependency graph
17
+ // πŸ“– (react-table, react-virtual, tabler…), same as the root vite.config.js
18
+ // πŸ“– used for production builds. Without this, pnpm's nested peer-dep
19
+ // πŸ“– resolution can make Vite's esbuild pre-bundler ship two copies of
20
+ // πŸ“– React β†’ "Invalid hook call" crashes at runtime in dev too.
21
+ resolve: {
22
+ dedupe: ['react', 'react-dom'],
23
+ },
16
24
  build: {
17
25
  outDir: 'dist',
18
26
  emptyOutDir: true,