From ec7ecd5461952b098340fdccbc48587d987a2991 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Fri, 24 Apr 2026 21:29:05 -0700 Subject: [PATCH] fix(canvas): load monorepo .env in next.config so WS connects in dev MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symptom: spawn animation missing on org import. Workspaces appeared in their final positions all at once instead of materialising one-by-one. Root cause: the WS pill said "Reconnecting" forever because the canvas was trying to connect to ws://localhost:3000/ws — its own port, where Next.js dev doesn't serve a WebSocket — instead of the platform's ws://localhost:8080/ws. Why: deriveWsBaseUrl() falls back to window.location when NEXT_PUBLIC_WS_URL is unset. Next.js auto-loads .env from the project root only — and the canonical NEXT_PUBLIC_WS_URL / NEXT_PUBLIC_PLATFORM_URL live in the monorepo root .env, alongside the Go platform's MOLECULE_ENV / DATABASE_URL. Without an extra canvas/.env.local copy (which would still be a per-developer manual step), the canvas dev server starts blind to those vars. Fix: next.config.ts now walks upward from __dirname looking for the monorepo root (same workspace-server/go.mod sentinel the platform's dotenv loader uses) and merges the root .env into process.env BEFORE Next.js compiles. Existing env wins over file values, so docker runs / CI / explicit exports still dominate. The parser is a TypeScript mirror of workspace-server/cmd/server/ dotenv.go's parseDotEnvLine — same rules (export prefix, quotes, inline comments, BOM) so a single .env line behaves identically across both processes. If one parser changes, the other has to. Production unaffected: `output: "standalone"` bakes resolved env into the build, the workspace-server sentinel isn't shipped in deploy artifacts, and the existing-env-wins rule means container env dominates anywhere this file is consulted at runtime. Verified: canvas dev startup log now shows "[next.config] loaded 49 vars from /Users/.../molecule-core/.env"; served bundle has the correct ws://localhost:8080/ws URL; WS pill flips to "Connected" after a hard refresh and per-workspace spawn animations fire on the next org import as expected. Co-Authored-By: Claude Opus 4.7 (1M context) --- canvas/next.config.ts | 81 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/canvas/next.config.ts b/canvas/next.config.ts index 68a6c64d..08c559cf 100644 --- a/canvas/next.config.ts +++ b/canvas/next.config.ts @@ -1,7 +1,88 @@ import type { NextConfig } from "next"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +// Load NEXT_PUBLIC_* vars from the monorepo root .env so a fresh +// `pnpm dev` works without a per-developer canvas/.env.local. Next.js +// only auto-loads .env from the project root by default — but our +// canonical config (NEXT_PUBLIC_PLATFORM_URL, NEXT_PUBLIC_WS_URL, +// MOLECULE_ENV, etc.) lives at the monorepo root, gitignored, shared +// by the Go platform binary. Without this, the canvas falls back to +// `window.location` (`ws://localhost:3000/ws`) and the WS pill stays +// "Reconnecting" forever because Next.js dev doesn't serve /ws. +// +// Mirrors workspace-server/cmd/server/dotenv.go's monorepo-rooted .env +// loader. Both processes look for the SAME marker (`workspace-server/ +// go.mod`) so a developer renaming or relocating the repo only has to +// update one heuristic. Production is unaffected: `output: "standalone"` +// bakes resolved env into the build, and the marker file isn't shipped. +loadMonorepoEnv(); const nextConfig: NextConfig = { output: "standalone", }; export default nextConfig; + +function loadMonorepoEnv() { + const root = findMonorepoRoot(__dirname); + if (!root) return; + const envPath = join(root, ".env"); + if (!existsSync(envPath)) return; + const body = readFileSync(envPath, "utf8"); + let loaded = 0; + let skipped = 0; + for (const line of body.split(/\r?\n/)) { + const kv = parseLine(line); + if (!kv) continue; + const [k, v] = kv; + if (process.env[k] !== undefined) { + skipped++; + continue; + } + process.env[k] = v; + loaded++; + } + // eslint-disable-next-line no-console + console.log( + `[next.config] loaded ${loaded} vars from ${envPath} (${skipped} already set in env)`, + ); +} + +function findMonorepoRoot(start: string): string | null { + let dir = start; + for (let i = 0; i < 6; i++) { + if (existsSync(join(dir, "workspace-server", "go.mod"))) return dir; + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + return null; +} + +// Mirror of workspace-server/cmd/server/dotenv.go's parseDotEnvLine +// — same rules so the two loaders agree on every line in the shared +// .env. If you change one parser, change the other. +function parseLine(raw: string): [string, string] | null { + let line = raw.replace(/^/, "").trim(); + if (line === "" || line.startsWith("#")) return null; + if (line.startsWith("export ")) line = line.slice("export ".length).trimStart(); + const eq = line.indexOf("="); + if (eq <= 0) return null; + const k = line.slice(0, eq).trim(); + let v = line.slice(eq + 1).replace(/^[ \t]+/, ""); + if (v.length >= 2 && (v[0] === '"' || v[0] === "'")) { + const quote = v[0]; + const end = v.indexOf(quote, 1); + if (end >= 0) return [k, v.slice(1, end)]; + // unterminated — fall through to bare-value handling + } + for (let i = 0; i < v.length; i++) { + if (v[i] !== "#") continue; + if (i === 0 || v[i - 1] === " " || v[i - 1] === "\t") { + v = v.slice(0, i); + break; + } + } + return [k, v.trim()]; +}