| 1 | import * as NodeZlib from "node:zlib"; |
| 2 | |
| 3 | import react, { reactCompilerPreset } from "@vitejs/plugin-react"; |
| 4 | import babel from "@rolldown/plugin-babel"; |
| 5 | import { tanstackRouter } from "@tanstack/router-plugin/vite"; |
| 6 | import compression from "compression"; |
| 7 | import { defineProject, type TestProjectInlineConfiguration } from "vite-plus/test/config"; |
| 8 | import "vite-plus/test/config"; |
| 9 | import { defineConfig, type Connect, type Plugin } from "vite-plus"; |
| 10 | import pkg from "./package.json" with { type: "json" }; |
| 11 | |
| 12 | import { |
| 13 | DEV_PROXIED_ORIGIN_PRESERVING_PREFIXES, |
| 14 | DEV_PROXIED_PATH_PREFIXES, |
| 15 | } from "@t3tools/shared/devProxy"; |
| 16 | |
| 17 | import { loadRepoEnv } from "../../scripts/lib/public-config"; |
| 18 | import { thirdPartyLicensesPlugin } from "../../scripts/lib/third-party-licenses"; |
| 19 | import { tailwindPlugins } from "./vite/tailwind"; |
| 20 | |
| 21 | const repoEnv = loadRepoEnv(); |
| 22 | Object.assign(process.env, repoEnv); |
| 23 | |
| 24 | // Single-origin dev is signalled positively, because it cannot be inferred |
| 25 | // from the absence of VITE_HTTP_URL/VITE_WS_URL: the runner deletes those keys |
| 26 | // but `loadRepoEnv` merges `.env`/`.env.local` *underneath* the process env, so |
| 27 | // a developer with either URL in their `.env` gets it back here. Baking it then |
| 28 | // pins the client to localhost and breaks every non-localhost origin — the |
| 29 | // exact failure single-origin mode exists to prevent, and an invisible one |
| 30 | // since the page still loads. |
| 31 | const isSingleOriginDev = process.env.T3CODE_SINGLE_ORIGIN_DEV === "1"; |
| 32 | |
| 33 | const port = Number(process.env.PORT ?? 5733); |
| 34 | const explicitHost = process.env.HOST?.trim(); |
| 35 | const host = explicitHost || "localhost"; |
| 36 | const configuredWsUrl = isSingleOriginDev ? undefined : process.env.VITE_WS_URL?.trim(); |
| 37 | const configuredHttpUrl = isSingleOriginDev ? undefined : process.env.VITE_HTTP_URL?.trim(); |
| 38 | const configuredRelayUrl = repoEnv.VITE_T3CODE_RELAY_URL?.trim() || ""; |
| 39 | const configuredClerkPublishableKey = repoEnv.VITE_CLERK_PUBLISHABLE_KEY?.trim() || ""; |
| 40 | const configuredClerkJwtTemplate = repoEnv.VITE_CLERK_JWT_TEMPLATE?.trim() || ""; |
| 41 | const configuredClerkCliOAuthClientId = repoEnv.VITE_CLERK_CLI_OAUTH_CLIENT_ID?.trim() || ""; |
| 42 | const configuredHostedAppChannel = process.env.VITE_HOSTED_APP_CHANNEL?.trim() || ""; |
| 43 | const configuredAppVersion = process.env.APP_VERSION?.trim() || pkg.version; |
| 44 | const configuredHostedAppUrl = (() => { |
| 45 | const explicitHostedAppUrl = process.env.VITE_HOSTED_APP_URL?.trim(); |
| 46 | if (explicitHostedAppUrl) { |
| 47 | return explicitHostedAppUrl; |
| 48 | } |
| 49 | if (process.env.VERCEL_ENV === "production" && process.env.VERCEL_PROJECT_PRODUCTION_URL) { |
| 50 | return `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`; |
| 51 | } |
| 52 | if (process.env.VERCEL_URL) { |
| 53 | return `https://${process.env.VERCEL_URL}`; |
| 54 | } |
| 55 | return undefined; |
| 56 | })(); |
| 57 | const sourcemapEnv = process.env.T3CODE_WEB_SOURCEMAP?.trim().toLowerCase(); |
| 58 | |
| 59 | // Vite 8.1's experimental bundled dev mode: serves rolldown-bundled chunks in |
| 60 | // dev for much faster startup/reload on large module graphs, with HMR served |
| 61 | // as hot patches. Opt-in while experimental: T3CODE_BUNDLED_DEV=1 pnpm dev:web |
| 62 | // The dev runner defaults this on for --share runs (remote browsers pay a |
| 63 | // round trip per import level in unbundled dev); T3CODE_BUNDLED_DEV=0 opts out. |
| 64 | const bundledDevEnv = process.env.T3CODE_BUNDLED_DEV?.trim().toLowerCase(); |
| 65 | const bundledDev = bundledDevEnv === "1" || bundledDevEnv === "true"; |
| 66 | |
| 67 | const buildSourcemap: boolean | "hidden" = |
| 68 | sourcemapEnv === "0" || sourcemapEnv === "false" |
| 69 | ? false |
| 70 | : sourcemapEnv === "hidden" |
| 71 | ? "hidden" |
| 72 | : true; |
| 73 | |
| 74 | const unitTestProject = { |
| 75 | extends: true, |
| 76 | test: { |
| 77 | name: "unit", |
| 78 | include: ["src/**/*.test.{ts,tsx}"], |
| 79 | // The web runtime suite exercises auth bootstrap, saved environments, |
| 80 | // and websocket subscription lifecycles. Under the full monorepo test |
| 81 | // run, those async tests can exceed Vitest's default 5s budget. |
| 82 | hookTimeout: 15_000, |
| 83 | testTimeout: 15_000, |
| 84 | setupFiles: ["../../packages/shared/src/testing/longTempDir.ts"], |
| 85 | }, |
| 86 | } satisfies TestProjectInlineConfiguration; |
| 87 | |
| 88 | function resolveDevProxyTarget( |
| 89 | backendPort: string | undefined, |
| 90 | wsUrl: string | undefined, |
| 91 | ): string | undefined { |
| 92 | // Browser dev is single-origin: the backend port is proxied through this |
| 93 | // server so the app works from any origin (localhost, tailnet, LAN, phone). |
| 94 | // T3CODE_PORT is set by scripts/dev-runner.ts for every non-desktop mode. |
| 95 | const port = Number(backendPort?.trim()); |
| 96 | if (Number.isInteger(port) && port > 0) { |
| 97 | return `http://localhost:${port}/`; |
| 98 | } |
| 99 | |
| 100 | // dev:desktop still points the renderer straight at the backend, so fall |
| 101 | // back to deriving the target from the explicit websocket URL. |
| 102 | if (!wsUrl) { |
| 103 | return undefined; |
| 104 | } |
| 105 | |
| 106 | try { |
| 107 | const url = new URL(wsUrl); |
| 108 | if (url.protocol === "ws:") { |
| 109 | url.protocol = "http:"; |
| 110 | } else if (url.protocol === "wss:") { |
| 111 | url.protocol = "https:"; |
| 112 | } |
| 113 | url.pathname = ""; |
| 114 | url.search = ""; |
| 115 | url.hash = ""; |
| 116 | return url.toString(); |
| 117 | } catch { |
| 118 | return undefined; |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | const devProxyTarget = resolveDevProxyTarget(process.env.T3CODE_PORT, configuredWsUrl); |
| 123 | |
| 124 | // Vite's dev server sends JS uncompressed. On localhost that is free; over a |
| 125 | // shared origin (tailnet, LAN) it is the whole cold-start: bundled dev serves |
| 126 | // one ~25 MB chunk, and a typical uplink moves that in about a minute while |
| 127 | // both machines sit idle. Compressing turns it into a few seconds of CPU. |
| 128 | // Brotli quality 5 keeps encode time in the hundreds of ms; the default |
| 129 | // (quality 11) would trade the transfer stall for an equally long encode stall. |
| 130 | function devCompressionPlugin(): Plugin { |
| 131 | return { |
| 132 | name: "t3code:dev-compression", |
| 133 | apply: "serve", |
| 134 | configureServer(server) { |
| 135 | // compression() is typed against Express's req/res, which extend the |
| 136 | // node http objects Connect actually passes — safe to narrow. |
| 137 | server.middlewares.use( |
| 138 | compression({ |
| 139 | brotli: { params: { [NodeZlib.constants.BROTLI_PARAM_QUALITY]: 5 } }, |
| 140 | }) as unknown as Connect.NextHandleFunction, |
| 141 | ); |
| 142 | }, |
| 143 | }; |
| 144 | } |
| 145 | |
| 146 | // Vite rejects requests whose Host header isn't localhost, which blocks sharing |
| 147 | // a dev server over Tailscale/LAN. Tailnet names are safe to allow wholesale: |
| 148 | // the DNS is controlled by tailscale, so they can't be rebound by an attacker. |
| 149 | // Anything else (ngrok, a LAN IP alias) goes through the env var. |
| 150 | const configuredAllowedHosts = (process.env.T3CODE_DEV_ALLOWED_HOSTS ?? "") |
| 151 | .split(",") |
| 152 | .map((entry) => entry.trim()) |
| 153 | .filter((entry) => entry.length > 0); |
| 154 | const allowedHosts = [".ts.net", ...configuredAllowedHosts]; |
| 155 | |
| 156 | export default defineConfig(() => { |
| 157 | return { |
| 158 | assetsInclude: ["**/*.wasm"], |
| 159 | plugins: [ |
| 160 | devCompressionPlugin(), |
| 161 | thirdPartyLicensesPlugin({ |
| 162 | bundleName: "web", |
| 163 | configFile: new URL("../../third-party-licenses.config.json", import.meta.url), |
| 164 | packageManifests: [ |
| 165 | { bundle: "web", path: new URL("./package.json", import.meta.url) }, |
| 166 | { bundle: "server", path: new URL("../server/package.json", import.meta.url) }, |
| 167 | { bundle: "desktop", path: new URL("../desktop/package.json", import.meta.url) }, |
| 168 | ], |
| 169 | }), |
| 170 | // Route components load as split chunks so settings, pull-request, and |
| 171 | // usage code stay out of the cold-start payload; the router prefetches |
| 172 | // them on navigation intent (see getRouter's defaultPreload). |
| 173 | tanstackRouter({ autoCodeSplitting: true }), |
| 174 | react(), |
| 175 | babel({ |
| 176 | // We need to be explicit about the parser options after moving to @vitejs/plugin-react v6.0.0 |
| 177 | // This is because the babel plugin only automatically parses typescript and jsx based on relative paths (e.g. "**/*.ts") |
| 178 | // whereas the previous version of the plugin parsed all files with a .ts extension. |
| 179 | // This is causing our packages/ directory to fail to parse, as they are not relative to the CWD. |
| 180 | parserOpts: { plugins: ["typescript", "jsx"] }, |
| 181 | presets: [reactCompilerPreset()], |
| 182 | }), |
| 183 | tailwindPlugins(bundledDev), |
| 184 | ], |
| 185 | optimizeDeps: { |
| 186 | include: [ |
| 187 | "@clerk/clerk-js", |
| 188 | "@clerk/react/internal", |
| 189 | "@pierre/diffs", |
| 190 | "@pierre/diffs/edit", |
| 191 | "@pierre/diffs/react", |
| 192 | "@pierre/diffs/worker/worker.js", |
| 193 | "effect/Array", |
| 194 | "effect/Order", |
| 195 | "react-dom/client", |
| 196 | ], |
| 197 | }, |
| 198 | define: { |
| 199 | // In dev mode, tell the web app where the WebSocket server lives |
| 200 | "import.meta.env.VITE_WS_URL": JSON.stringify(configuredWsUrl ?? ""), |
| 201 | // Pinned explicitly rather than left to Vite's automatic VITE_ exposure: |
| 202 | // under single-origin dev this must stay empty even when a `.env` |
| 203 | // supplies it, so the client falls back to window.location.origin. |
| 204 | "import.meta.env.VITE_HTTP_URL": JSON.stringify(configuredHttpUrl ?? ""), |
| 205 | "import.meta.env.VITE_T3CODE_RELAY_URL": JSON.stringify(configuredRelayUrl), |
| 206 | "import.meta.env.VITE_CLERK_PUBLISHABLE_KEY": JSON.stringify(configuredClerkPublishableKey), |
| 207 | "import.meta.env.VITE_CLERK_JWT_TEMPLATE": JSON.stringify(configuredClerkJwtTemplate), |
| 208 | "import.meta.env.VITE_CLERK_CLI_OAUTH_CLIENT_ID": JSON.stringify( |
| 209 | configuredClerkCliOAuthClientId, |
| 210 | ), |
| 211 | "import.meta.env.VITE_HOSTED_APP_URL": JSON.stringify(configuredHostedAppUrl ?? ""), |
| 212 | "import.meta.env.VITE_HOSTED_APP_CHANNEL": JSON.stringify(configuredHostedAppChannel), |
| 213 | "import.meta.env.APP_VERSION": JSON.stringify(configuredAppVersion), |
| 214 | }, |
| 215 | resolve: { |
| 216 | tsconfigPaths: true, |
| 217 | dedupe: ["react", "react-dom"], |
| 218 | }, |
| 219 | experimental: { |
| 220 | bundledDev, |
| 221 | }, |
| 222 | server: { |
| 223 | host, |
| 224 | port, |
| 225 | strictPort: true, |
| 226 | allowedHosts, |
| 227 | // Transform the whole module graph at server start instead of on the |
| 228 | // first request. Without this, a cold worktree discovers and transforms |
| 229 | // modules one import-level at a time while the browser waits — which |
| 230 | // over a tailnet origin turns into minutes of waterfall. |
| 231 | warmup: { |
| 232 | clientFiles: ["./src/main.tsx"], |
| 233 | }, |
| 234 | ...(devProxyTarget |
| 235 | ? { |
| 236 | // One entry per shared prefix; the server's dev catch-all 404s the |
| 237 | // same list, so the two sides cannot drift. `/ws` is the app's own |
| 238 | // socket and `/api` carries the device hub's stream sockets — |
| 239 | // Vite's HMR socket is matched separately and exactly (path "/" |
| 240 | // plus a vite-hmr subprotocol), so the upgrade handlers don't |
| 241 | // collide. |
| 242 | proxy: Object.fromEntries( |
| 243 | DEV_PROXIED_PATH_PREFIXES.map((prefix) => [ |
| 244 | prefix, |
| 245 | { |
| 246 | target: devProxyTarget, |
| 247 | changeOrigin: !DEV_PROXIED_ORIGIN_PRESERVING_PREFIXES.has(prefix), |
| 248 | ...(prefix === "/ws" || prefix === "/api" || prefix === "/environments" |
| 249 | ? { ws: true } |
| 250 | : {}), |
| 251 | }, |
| 252 | ]), |
| 253 | ), |
| 254 | } |
| 255 | : {}), |
| 256 | // Electron's BrowserWindow needs the HMR socket pinned to an explicit |
| 257 | // host to connect reliably; dev:desktop is the only mode that sets HOST. |
| 258 | // Everywhere else, leaving this unset lets the client derive it from the |
| 259 | // page origin, which is what makes HMR work over Tailscale/LAN instead of |
| 260 | // failing an attempt against the wrong machine's localhost first. |
| 261 | // (Vite 8 logs connection state via console.debug — enable "Verbose".) |
| 262 | ...(explicitHost |
| 263 | ? { |
| 264 | hmr: { |
| 265 | protocol: "ws", |
| 266 | host: explicitHost, |
| 267 | clientPort: port, |
| 268 | }, |
| 269 | } |
| 270 | : {}), |
| 271 | }, |
| 272 | // @tailwindcss/vite only emits a CSS sourcemap when devSourcemap is on; without it |
| 273 | // rolldown flags the transform as SOURCEMAP_BROKEN on every sourcemapped build. |
| 274 | css: { |
| 275 | devSourcemap: buildSourcemap !== false, |
| 276 | }, |
| 277 | build: { |
| 278 | outDir: "dist", |
| 279 | emptyOutDir: true, |
| 280 | manifest: true, |
| 281 | sourcemap: buildSourcemap, |
| 282 | }, |
| 283 | test: { |
| 284 | projects: [defineProject(unitTestProject)], |
| 285 | }, |
| 286 | }; |
| 287 | }); |