From e51df5428321f75c60ae574f745bc71ed621627b Mon Sep 17 00:00:00 2001 From: Charlie Lamb Date: Thu, 26 Feb 2026 20:10:16 +0000 Subject: [PATCH 1/2] chore: add dx for worktrees --- package.json | 1 + scripts/dev.ts | 75 +++++++++++++++++++++------ scripts/dx.ts | 38 ++++++++++++++ server/src/initHono.ts | 21 +------- server/src/utils/auth.ts | 5 ++ server/src/utils/corsOrigins.ts | 29 +++++++++++ server/tests/unit/corsOrigins.test.ts | 66 +++++++++++++++++++++++ 7 files changed, 199 insertions(+), 36 deletions(-) create mode 100644 scripts/dx.ts create mode 100644 server/src/utils/corsOrigins.ts create mode 100644 server/tests/unit/corsOrigins.test.ts diff --git a/package.json b/package.json index 020a48a0d..700a1fea3 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "vite:build": "bun -F @autumn/vite build:bun", "t": "infisical run --env=dev -- bun scripts/testScripts/testDispatcher.ts", "d": "lsof -ti:8080 -ti:3000 | xargs kill -9 2>/dev/null || true; ENV_FILE=.env infisical run --env=dev -- bun scripts/dev.ts", + "dx": "bun scripts/dx.ts", "d:test": "lsof -ti:8080 -ti:3000 | xargs kill -9 2>/dev/null || true; ENV_FILE=.env infisical run --env=test -- bun scripts/dev.ts", "p": "ENV_FILE=.env.prod infisical run --env=prod -- bun scripts/dev.ts", "setup": "node scripts/setup/setup.js", diff --git a/scripts/dev.ts b/scripts/dev.ts index 2cb9a1cd2..153cdd541 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -2,9 +2,17 @@ import { existsSync, readFileSync, rmSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -const VITE_PORT = 3000; -const SERVER_PORT = 8080; -const CHECKOUT_PORT = 3001; +const worktreeIdx = process.argv.indexOf("--worktree"); +const worktreeNum = + worktreeIdx !== -1 && process.argv[worktreeIdx + 1] + ? Number.parseInt(process.argv[worktreeIdx + 1], 10) + : 1; +const portOffset = (worktreeNum - 1) * 100; + +const VITE_PORT = 3000 + portOffset; +const SERVER_PORT = 8080 + portOffset; +const CHECKOUT_PORT = 3001 + portOffset; +const skipWorkers = worktreeNum > 1; /** * Read environment variable from .env file @@ -71,7 +79,15 @@ async function startDev() { } } - console.log("Starting development servers...\n"); + if (worktreeNum > 1) { + console.log(`Starting worktree ${worktreeNum} (no workers)...\n`); + } else { + console.log("Starting development servers...\n"); + } + + console.log(` vite: http://localhost:${VITE_PORT}`); + console.log(` server: http://localhost:${SERVER_PORT}`); + console.log(` checkout: http://localhost:${CHECKOUT_PORT}\n`); // Use cmd on Windows, sh on Unix const isWindows = process.platform === "win32"; @@ -94,21 +110,40 @@ async function startDev() { `bunx concurrently -n server,workers -c green,yellow "cd server && SERVER_PORT=${SERVER_PORT} bun start" "cd server && bun workers"`, ]; } - } else if (isWindows) { - const serverCmd = `cd server && set SERVER_PORT=${SERVER_PORT} && bun dev`; - const workersCmd = `cd server && bun workers:dev`; - const viteCmd = `cd vite && set VITE_PORT=${VITE_PORT} && bun dev`; - const checkoutCmd = `cd apps/checkout && set VITE_PORT=${CHECKOUT_PORT} && bun dev`; - shellArgs = [ - "cmd", - "/c", - `bunx concurrently -n server,workers,vite,checkout -c green,yellow,blue,magenta "${serverCmd}" "${workersCmd}" "${viteCmd}" "${checkoutCmd}"`, - ]; } else { + const names = ["server"]; + const colors = ["green"]; + const cmds = [ + isWindows + ? `"cd server && set SERVER_PORT=${SERVER_PORT} && bun dev"` + : `"cd server && SERVER_PORT=${SERVER_PORT} bun dev"`, + ]; + + if (!skipWorkers) { + names.push("workers"); + colors.push("yellow"); + cmds.push( + isWindows + ? `"cd server && bun workers:dev"` + : `"cd server && bun workers:dev"`, + ); + } + + names.push("vite", "checkout"); + colors.push("blue", "magenta"); + cmds.push( + isWindows + ? `"cd vite && set VITE_PORT=${VITE_PORT} && bun dev"` + : `"cd vite && VITE_PORT=${VITE_PORT} bun dev"`, + isWindows + ? `"cd apps/checkout && set VITE_PORT=${CHECKOUT_PORT} && bun dev"` + : `"cd apps/checkout && VITE_PORT=${CHECKOUT_PORT} bun dev"`, + ); + shellArgs = [ - "sh", - "-c", - `bunx concurrently -n server,workers,vite,checkout -c green,yellow,blue,magenta "cd server && SERVER_PORT=${SERVER_PORT} bun dev" "cd server && bun workers:dev" "cd vite && VITE_PORT=${VITE_PORT} bun dev" "cd apps/checkout && VITE_PORT=${CHECKOUT_PORT} bun dev"`, + isWindows ? "cmd" : "sh", + isWindows ? "/c" : "-c", + `bunx concurrently -n ${names.join(",")} -c ${colors.join(",")} ${cmds.join(" ")}`, ]; } @@ -119,6 +154,12 @@ async function startDev() { VITE_PORT: VITE_PORT.toString(), SERVER_PORT: SERVER_PORT.toString(), CHECKOUT_PORT: CHECKOUT_PORT.toString(), + ...(worktreeNum > 1 && { + CLIENT_URL: `http://localhost:${VITE_PORT}`, + BETTER_AUTH_URL: `http://localhost:${SERVER_PORT}`, + VITE_BACKEND_URL: `http://localhost:${SERVER_PORT}`, + VITE_FRONTEND_URL: `http://localhost:${VITE_PORT}`, + }), }, stdout: "inherit", stderr: "inherit", diff --git a/scripts/dx.ts b/scripts/dx.ts new file mode 100644 index 000000000..fec0dc813 --- /dev/null +++ b/scripts/dx.ts @@ -0,0 +1,38 @@ +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const worktreeNum = Number.parseInt(process.argv[2] || "", 10); +if (Number.isNaN(worktreeNum) || worktreeNum < 2) { + console.error("Usage: bun dx (where N >= 2)"); + console.error(" e.g. bun dx 2 -> vite:3100, server:8180, checkout:3101"); + process.exit(1); +} + +const offset = (worktreeNum - 1) * 100; +const vitePort = 3000 + offset; +const serverPort = 8080 + offset; +const checkoutPort = 3001 + offset; + +const portArgs = [ + `-ti:${vitePort}`, + `-ti:${serverPort}`, + `-ti:${checkoutPort}`, +].join(" "); +const killCmd = `lsof ${portArgs} | xargs kill -9 2>/dev/null || true`; +const devCmd = `ENV_FILE=.env infisical run --env=dev -- bun scripts/dev.ts --worktree ${worktreeNum}`; + +const rootDir = dirname(fileURLToPath(import.meta.url)); +const projectRoot = join(rootDir, ".."); + +const proc = Bun.spawn(["sh", "-c", `${killCmd}; ${devCmd}`], { + cwd: projectRoot, + env: process.env, + stdout: "inherit", + stderr: "inherit", +}); + +process.on("SIGINT", () => proc.kill("SIGINT")); +process.on("SIGTERM", () => proc.kill("SIGTERM")); + +await proc.exited; +process.exit(proc.exitCode ?? 0); diff --git a/server/src/initHono.ts b/server/src/initHono.ts index f7b111416..dfe4871e6 100644 --- a/server/src/initHono.ts +++ b/server/src/initHono.ts @@ -22,24 +22,7 @@ import { apiRouter } from "./routers/apiRouter.js"; import { internalRouter } from "./routers/internalRouter.js"; import { publicRouter } from "./routers/publicRouter.js"; import { auth } from "./utils/auth.js"; - -const ALLOWED_ORIGINS = [ - "http://localhost:3000", - "http://localhost:3001", - "http://localhost:3002", - "http://localhost:3003", - "http://localhost:3004", - "http://localhost:3005", - "http://localhost:3006", - "http://localhost:3007", - "http://localhost:5173", - "http://localhost:5174", - "https://app.useautumn.com", - "https://staging.useautumn.com", - "https://dev.useautumn.com", - "https://api.staging.useautumn.com", - "https://localhost:8080", -]; +import { isAllowedOrigin } from "./utils/corsOrigins.js"; const ALLOWED_HEADERS = [ "app_env", @@ -72,7 +55,7 @@ export const createHonoApp = () => { app.use( "*", cors({ - origin: ALLOWED_ORIGINS, + origin: isAllowedOrigin, allowHeaders: ALLOWED_HEADERS, allowMethods: ["POST", "GET", "PUT", "DELETE", "PATCH", "OPTIONS"], exposeHeaders: ["Content-Length"], diff --git a/server/src/utils/auth.ts b/server/src/utils/auth.ts index 0b84c5dc2..77998ea20 100644 --- a/server/src/utils/auth.ts +++ b/server/src/utils/auth.ts @@ -75,6 +75,11 @@ export const auth = betterAuth({ for (let i = 0; i <= 10; i++) { origins.push(`http://localhost:${3000 + i}`); } + + // Support multi-worktree dev with offset ports (e.g. localhost:3100) + if (process.env.CLIENT_URL) { + origins.push(process.env.CLIENT_URL); + } } return origins; diff --git a/server/src/utils/corsOrigins.ts b/server/src/utils/corsOrigins.ts new file mode 100644 index 000000000..0413b47b2 --- /dev/null +++ b/server/src/utils/corsOrigins.ts @@ -0,0 +1,29 @@ +export const ALLOWED_ORIGINS = [ + "http://localhost:3000", + "http://localhost:3001", + "http://localhost:3002", + "http://localhost:3003", + "http://localhost:3004", + "http://localhost:3005", + "http://localhost:3006", + "http://localhost:3007", + "http://localhost:5173", + "http://localhost:5174", + "https://app.useautumn.com", + "https://staging.useautumn.com", + "https://dev.useautumn.com", + "https://api.staging.useautumn.com", + "https://localhost:8080", +]; + +/** Allow any localhost origin in dev for multi-worktree support */ +export const isAllowedOrigin = (origin: string): string | undefined => { + if (ALLOWED_ORIGINS.includes(origin)) return origin; + if ( + process.env.NODE_ENV !== "production" && + /^https?:\/\/localhost:\d+$/.test(origin) + ) { + return origin; + } + return undefined; +}; diff --git a/server/tests/unit/corsOrigins.test.ts b/server/tests/unit/corsOrigins.test.ts new file mode 100644 index 000000000..23b237998 --- /dev/null +++ b/server/tests/unit/corsOrigins.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { ALLOWED_ORIGINS, isAllowedOrigin } from "@/utils/corsOrigins.js"; + +describe("isAllowedOrigin", () => { + const originalNodeEnv = process.env.NODE_ENV; + + afterEach(() => { + process.env.NODE_ENV = originalNodeEnv; + }); + + describe("production", () => { + test("allows hardcoded production origins", () => { + process.env.NODE_ENV = "production"; + for (const origin of ALLOWED_ORIGINS) { + expect(isAllowedOrigin(origin)).toBe(origin); + } + }); + + test("rejects arbitrary localhost ports", () => { + process.env.NODE_ENV = "production"; + expect(isAllowedOrigin("http://localhost:3100")).toBeUndefined(); + expect(isAllowedOrigin("http://localhost:8180")).toBeUndefined(); + expect(isAllowedOrigin("http://localhost:9999")).toBeUndefined(); + }); + + test("rejects external origins", () => { + process.env.NODE_ENV = "production"; + expect(isAllowedOrigin("https://evil.com")).toBeUndefined(); + expect(isAllowedOrigin("https://fake.useautumn.com")).toBeUndefined(); + }); + }); + + describe("non-production", () => { + test("allows hardcoded origins", () => { + process.env.NODE_ENV = "development"; + for (const origin of ALLOWED_ORIGINS) { + expect(isAllowedOrigin(origin)).toBe(origin); + } + }); + + test("allows any localhost port (worktree offsets)", () => { + process.env.NODE_ENV = "development"; + expect(isAllowedOrigin("http://localhost:3100")).toBe( + "http://localhost:3100", + ); + expect(isAllowedOrigin("http://localhost:8180")).toBe( + "http://localhost:8180", + ); + expect(isAllowedOrigin("http://localhost:3200")).toBe( + "http://localhost:3200", + ); + }); + + test("rejects external origins", () => { + process.env.NODE_ENV = "development"; + expect(isAllowedOrigin("https://evil.com")).toBeUndefined(); + expect(isAllowedOrigin("http://evil.com:3000")).toBeUndefined(); + }); + + test("rejects localhost with path or query", () => { + process.env.NODE_ENV = "development"; + expect(isAllowedOrigin("http://localhost:3000/evil")).toBeUndefined(); + expect(isAllowedOrigin("http://localhost:3000?x=1")).toBeUndefined(); + }); + }); +}); From 25ea67465fb91504f72ecc39f75c3e5d94e01473 Mon Sep 17 00:00:00 2001 From: Charlie Lamb Date: Thu, 26 Feb 2026 20:12:37 +0000 Subject: [PATCH 2/2] chore: add dx port detection --- scripts/dx.ts | 36 ++++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/scripts/dx.ts b/scripts/dx.ts index fec0dc813..90585f221 100644 --- a/scripts/dx.ts +++ b/scripts/dx.ts @@ -1,18 +1,46 @@ +import { createConnection } from "node:net"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -const worktreeNum = Number.parseInt(process.argv[2] || "", 10); -if (Number.isNaN(worktreeNum) || worktreeNum < 2) { - console.error("Usage: bun dx (where N >= 2)"); - console.error(" e.g. bun dx 2 -> vite:3100, server:8180, checkout:3101"); +function isPortInUse({ port }: { port: number }): Promise { + return new Promise((resolve) => { + const socket = createConnection({ port, host: "127.0.0.1" }); + socket.on("connect", () => { + socket.destroy(); + resolve(true); + }); + socket.on("error", () => { + socket.destroy(); + resolve(false); + }); + }); +} + +async function findFreeWorktree(): Promise { + for (let n = 2; n <= 10; n++) { + const serverPort = 8080 + (n - 1) * 100; + if (!(await isPortInUse({ port: serverPort }))) return n; + } + console.error("No free worktree slots (2-10). All server ports in use."); process.exit(1); } +// Allow explicit override: `bun dx 3`, otherwise auto-detect +const explicitArg = Number.parseInt(process.argv[2] || "", 10); +const worktreeNum = + !Number.isNaN(explicitArg) && explicitArg >= 2 + ? explicitArg + : await findFreeWorktree(); + const offset = (worktreeNum - 1) * 100; const vitePort = 3000 + offset; const serverPort = 8080 + offset; const checkoutPort = 3001 + offset; +console.log( + `Worktree ${worktreeNum} -> vite:${vitePort}, server:${serverPort}, checkout:${checkoutPort}\n`, +); + const portArgs = [ `-ti:${vitePort}`, `-ti:${serverPort}`,