From e6c22ab7fb7524917367bd3619552b586e76049c Mon Sep 17 00:00:00 2001 From: Daniel Campagnoli Date: Sun, 19 Oct 2025 10:55:38 +0800 Subject: [PATCH 1/4] Fix dynamic ports --- frontend/scripts/env-utils.js | 6 +- src/cli/startLocal.ts | 101 +++++++++++++++++++++++++++++----- 2 files changed, 90 insertions(+), 17 deletions(-) diff --git a/frontend/scripts/env-utils.js b/frontend/scripts/env-utils.js index 32fbed44..07334d75 100644 --- a/frontend/scripts/env-utils.js +++ b/frontend/scripts/env-utils.js @@ -51,8 +51,6 @@ function hydrateProcessEnv() { } function determineBackendPort() { - if (process.env.BACKEND_PORT) return process.env.BACKEND_PORT; - if (process.env.PORT) return process.env.PORT; try { const runtimePath = path.resolve(process.cwd(), '../.typedai/runtime/backend.json'); if (fs.existsSync(runtimePath)) { @@ -62,6 +60,10 @@ function determineBackendPort() { } catch (error) { console.warn('Unable to read backend runtime metadata.', error); } + + if (process.env.BACKEND_PORT) return process.env.BACKEND_PORT; + if (process.env.PORT) return process.env.PORT; + return null; } diff --git a/src/cli/startLocal.ts b/src/cli/startLocal.ts index 9f9d7de2..e28b93fd 100644 --- a/src/cli/startLocal.ts +++ b/src/cli/startLocal.ts @@ -1,3 +1,17 @@ +/** + * @fileoverview + * This script is the entry point for starting the backend server in a local development + * environment. It is designed to handle the complexities of a multi-repository setup + * where developers might be running a fork of the main repository. + * + * Key features: + * - Dynamically finds available ports for the backend server and Node.js inspector + * to avoid conflicts, especially for contributors not using the default setup. + * - Resolves and loads environment variables from a `.env` file. + * - Writes a `backend.json` runtime metadata file that other processes (like the + * frontend dev server) can read to discover the backend's port. + * - Initializes and starts the Fastify server. + */ import '#fastify/trace-init/trace-init'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; @@ -21,10 +35,12 @@ type ParsedEnv = Record; /** * Bootstraps the local backend server with dynamic ports and env-file fallback. + * This function orchestrates the entire startup sequence for local development. */ async function main(): Promise { let envFilePath: string | undefined; try { + // 1. Resolve and apply environment variables from a `.env` file. envFilePath = resolveEnvFilePath(); applyEnvFile(envFilePath); } catch (err) { @@ -33,10 +49,15 @@ async function main(): Promise { process.env.NODE_ENV ??= 'development'; + // Determine if this is the "default" repository setup (e.g., the main repo) + // or a contributor's setup (e.g., a fork). This affects port handling. + // In the default setup, we use fixed ports (3000/9229) and fail if they're taken. + // In a contributor setup, we find the next available port to avoid conflicts. const repoRoot = path.resolve(process.cwd()); const typedAiHome = process.env.TYPEDAI_HOME ? path.resolve(process.env.TYPEDAI_HOME) : null; const isDefaultRepo = typedAiHome ? repoRoot === typedAiHome : false; + // 2. Determine and set the backend server port. const parsedPort = process.env.PORT ? Number.parseInt(process.env.PORT, 10) : undefined; let backendPort: number; if (isDefaultRepo) { @@ -45,9 +66,11 @@ async function main(): Promise { } else { backendPort = await findAvailablePort(Number.isFinite(parsedPort) ? parsedPort : 3000); } + // Set both PORT and BACKEND_PORT for compatibility with different consumers. process.env.PORT = backendPort.toString(); process.env.BACKEND_PORT = backendPort.toString(); + // 3. Determine and set the Node.js inspector port. const inspectorParsed = process.env.INSPECT_PORT ? Number.parseInt(process.env.INSPECT_PORT, 10) : undefined; let inspectPort: number; if (isDefaultRepo) { @@ -58,7 +81,10 @@ async function main(): Promise { } process.env.INSPECT_PORT = inspectPort.toString(); + // 4. Set environment variables that depend on the resolved ports. const apiBaseUrl = `http://localhost:${backendPort}/api/`; + // Only override API_BASE_URL if it's not set or points to the default port, + // allowing for custom configurations. if (!process.env.API_BASE_URL || process.env.API_BASE_URL.includes('localhost:3000')) { process.env.API_BASE_URL = apiBaseUrl; } @@ -75,12 +101,15 @@ async function main(): Promise { logger.info(`[start-local] backend listening on ${backendPort}`); logger.info(`[start-local] inspector listening on ${inspectPort}`); + // 5. Attempt to open the inspector in a browser. try { open(inspectPort, '0.0.0.0', false); } catch (error) { logger.warn(error, `[start-local] failed to open inspector on ${inspectPort}`); } + // 6. Write runtime metadata for other processes to consume. + // This allows the frontend dev server to know which port the backend is running on. const runtimeMetadataPath = path.join(process.cwd(), '.typedai', 'runtime', 'backend.json'); writeRuntimeMetadata(runtimeMetadataPath, { envFilePath, @@ -88,6 +117,7 @@ async function main(): Promise { inspectPort, }); + // 7. Start the server by requiring the main application entry point. const require = createRequire(__filename); require('../index'); } @@ -97,6 +127,12 @@ main().catch((error) => { process.exitCode = 1; }); +/** + * Builds an absolute path from a potential relative path. + * @param value The path value (can be null or undefined). + * @param cwd The current working directory to resolve from. + * @returns An absolute path, or null if the input value is empty. + */ function buildCandidatePath(value: string | null | undefined, cwd: string): string | null { if (!value) return null; if (isAbsolute(value)) return value; @@ -105,8 +141,11 @@ function buildCandidatePath(value: string | null | undefined, cwd: string): stri /** * Resolves the path to the env file used for local development. - * Resolution order: explicit ENV_FILE → `variables/local.env` in the cwd → - * `$TYPEDAI_HOME/variables/local.env`. + * Resolution order: + * 1. Explicit `ENV_FILE` environment variable. + * 2. `variables/local.env` relative to the current working directory. + * 3. `variables/local.env` inside the directory specified by `TYPEDAI_HOME`. + * @throws If no environment file can be found in any of the candidate locations. */ function resolveEnvFilePath(options: ResolveEnvFileOptions = {}): string { const cwd = options.cwd ?? process.cwd(); @@ -127,8 +166,15 @@ function resolveEnvFilePath(options: ResolveEnvFileOptions = {}): string { } /** - * Parses a dotenv style file into a plain key/value map. - * Lines without an equals sign or starting with `#` are ignored. + * Parses a dotenv-style file into a plain key/value map. + * - Ignores lines starting with `#` (comments). + * - Ignores lines without an equals sign. + * - Trims whitespace from keys and values. + * - Strips `export ` prefix from keys. + * - Removes quotes from values. + * - Converts `\n` literals to newlines. + * @param filePath The absolute path to the environment file. + * @returns A record of environment variables. */ function loadEnvFile(filePath: string): ParsedEnv { if (!existsSync(filePath)) throw new Error(`Environment file not found at ${filePath}`); @@ -160,8 +206,11 @@ function loadEnvFile(filePath: string): ParsedEnv { } /** - * Loads the file and assigns its values to `process.env`. - * Existing values are preserved unless `override` is set. + * Loads an environment file and assigns its values to `process.env`. + * By default, it does not override existing environment variables. + * @param filePath The path to the environment file. + * @param options Configuration options. `override: true` will cause it to + * overwrite existing `process.env` values. */ function applyEnvFile(filePath: string, options: ApplyEnvOptions = {}): void { const envVars = loadEnvFile(filePath); @@ -175,7 +224,10 @@ function applyEnvFile(filePath: string, options: ApplyEnvOptions = {}): void { /** * Writes JSON metadata describing the current runtime so other processes can - * discover the chosen configuration (e.g. ports). + * discover the chosen configuration (e.g., ports). This is crucial for the + * frontend dev server to connect to the correct backend port. + * @param targetPath The full path where the metadata file will be written. + * @param data The data object to serialize into the JSON file. */ function writeRuntimeMetadata(targetPath: string, data: Record): void { const dir = path.dirname(targetPath); @@ -189,19 +241,23 @@ let serverFactory: ServerFactory = () => createServer(); /** * Overrides the net server factory used when probing ports. - * Primarily for tests where opening real sockets would fail in a sandbox. + * This is primarily a testing utility to allow mocking of `net.createServer` + * in environments where opening real sockets is not possible or desired. + * @param factory A function that returns a `net.Server` instance, or null to reset. */ function setServerFactory(factory: ServerFactory | null): void { serverFactory = factory ?? (() => createServer()); } /** - * Attempts to find a free TCP port. Prefers the provided range before - * delegating to the OS (port 0). - */ -/** - * Attempts to find a free TCP port. Prefers the provided range before - * delegating to the OS (port 0). + * Attempts to find a free TCP port. + * It first checks the `preferred` port and a number of subsequent ports (`attempts`). + * If no port in that range is free, it falls back to asking the OS for any + * available port by trying to listen on port 0. + * @param preferred The starting port number to check. + * @param attempts The number of consecutive ports to try after `preferred`. + * @returns A promise that resolves with an available port number. + * @throws If no available port can be found. */ async function findAvailablePort(preferred?: number, attempts = 20): Promise { const ports: number[] = []; @@ -224,7 +280,13 @@ async function findAvailablePort(preferred?: number, attempts = 20): Promise { try { await tryListen(port); @@ -234,6 +296,15 @@ async function ensurePortAvailable(port: number): Promise { } } +/** + * Low-level utility to test if a port is available by creating a server, + * listening on the port, and then immediately closing it. + * @param port The port number to test. A value of 0 will cause the OS to + * assign an arbitrary available port. + * @returns A promise that resolves with the actual port number that was + * successfully bound. + * @rejects If the port is already in use or another error occurs. + */ async function tryListen(port: number): Promise { return await new Promise((resolve, reject) => { const server = serverFactory(); From ae1f924790e208ad2389e3fe0fd2e5791b1a3023 Mon Sep 17 00:00:00 2001 From: Daniel Campagnoli Date: Sun, 19 Oct 2025 11:02:31 +0800 Subject: [PATCH 2/4] fix: Prioritize dynamic backend port for API base URL in local env Co-authored-by: aider (vertex_ai/gemini-2.5-flash) --- frontend/scripts/env.js | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/frontend/scripts/env.js b/frontend/scripts/env.js index f4dd3aaa..c0caff36 100644 --- a/frontend/scripts/env.js +++ b/frontend/scripts/env.js @@ -10,7 +10,19 @@ function generateEnvironmentFile() { hydrateProcessEnv(); const backendPort = determineBackendPort(); - const resolvedApiBase = process.env.API_BASE_URL || (backendPort ? `http://localhost:${backendPort}/api/` : 'http://localhost:3000/api/'); + + let resolvedApiBase; + // When running locally, we must prioritize the dynamic backend port over any + // value from a .env file, which likely contains the default port. + if (backendPort) { + resolvedApiBase = `http://localhost:${backendPort}/api/`; + } + // For other cases (like CI builds), use the environment variable or a fallback. + else { + resolvedApiBase = + process.env.API_BASE_URL || 'http://localhost:3000/api/'; + } + const frontPort = determineFrontendPort(); const resolvedUiUrl = process.env.UI_URL || `http://localhost:${frontPort ?? '4200'}/`; From 8d591efa5155c9cbb43f684afed13ddc0b3e7976 Mon Sep 17 00:00:00 2001 From: Daniel Campagnoli Date: Sun, 19 Oct 2025 11:09:18 +0800 Subject: [PATCH 3/4] feat: Allow dynamic CORS origin for contributor dev environments Co-authored-by: aider (vertex_ai/gemini-2.5-flash) --- src/cli/startLocal.ts | 1 + src/fastify/fastifyApp.ts | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/cli/startLocal.ts b/src/cli/startLocal.ts index e28b93fd..1f4b2897 100644 --- a/src/cli/startLocal.ts +++ b/src/cli/startLocal.ts @@ -56,6 +56,7 @@ async function main(): Promise { const repoRoot = path.resolve(process.cwd()); const typedAiHome = process.env.TYPEDAI_HOME ? path.resolve(process.env.TYPEDAI_HOME) : null; const isDefaultRepo = typedAiHome ? repoRoot === typedAiHome : false; + process.env.TYPEDAI_PORT_MODE = isDefaultRepo ? 'fixed' : 'dynamic'; // 2. Determine and set the backend server port. const parsedPort = process.env.PORT ? Number.parseInt(process.env.PORT, 10) : undefined; diff --git a/src/fastify/fastifyApp.ts b/src/fastify/fastifyApp.ts index 0f13cd1a..f966934c 100644 --- a/src/fastify/fastifyApp.ts +++ b/src/fastify/fastifyApp.ts @@ -311,8 +311,21 @@ async function loadPlugins(config: FastifyConfig) { await fastifyInstance.register(import('@fastify/jwt'), { secret: process.env.JWT_SECRET || 'your-secret-key', }); + // Determine CORS origin policy based on the port mode set during startup. + let corsOrigin: string | boolean = new URL(process.env.UI_URL!).origin; + + // In a contributor's local development setup, ports are dynamic to avoid conflicts. + // In this "dynamic" mode, we cannot know the frontend's port at backend startup. + // To avoid CORS issues that block development, we relax the policy. + // `origin: true` reflects the request's origin, which is a safe way to allow any + // origin for credentialed requests in a development context. + // The 'fixed' mode is used for the default repository setup where ports are known and fixed. + if (process.env.TYPEDAI_PORT_MODE === 'dynamic') { + corsOrigin = true; + } + await fastifyInstance.register(import('@fastify/cors'), { - origin: [new URL(process.env.UI_URL!).origin], + origin: corsOrigin, methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization', 'X-Goog-Iap-Jwt-Assertion', 'Enctype', 'Accept'], credentials: true, From 2b4f3cc6504c43d27f996cbf9bbdb99f25dbb6ac Mon Sep 17 00:00:00 2001 From: Daniel Campagnoli Date: Sun, 19 Oct 2025 11:16:58 +0800 Subject: [PATCH 4/4] Update startLocal.ts --- src/cli/startLocal.ts | 60 +++++++++++++++++++++---------------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/src/cli/startLocal.ts b/src/cli/startLocal.ts index 1f4b2897..a2b60d58 100644 --- a/src/cli/startLocal.ts +++ b/src/cli/startLocal.ts @@ -33,6 +33,20 @@ interface ApplyEnvOptions { type ParsedEnv = Record; +type ServerFactory = () => NetServer; + +let serverFactory: ServerFactory = () => createServer(); + +/** + * Overrides the net server factory used when probing ports. + * This is primarily a testing utility to allow mocking of `net.createServer` + * in environments where opening real sockets is not possible or desired. + * @param factory A function that returns a `net.Server` instance, or null to reset. + */ +function setServerFactory(factory: ServerFactory | null): void { + serverFactory = factory ?? (() => createServer()); +} + /** * Bootstraps the local backend server with dynamic ports and env-file fallback. * This function orchestrates the entire startup sequence for local development. @@ -72,15 +86,15 @@ async function main(): Promise { process.env.BACKEND_PORT = backendPort.toString(); // 3. Determine and set the Node.js inspector port. - const inspectorParsed = process.env.INSPECT_PORT ? Number.parseInt(process.env.INSPECT_PORT, 10) : undefined; - let inspectPort: number; - if (isDefaultRepo) { - inspectPort = Number.isFinite(inspectorParsed) ? inspectorParsed! : 9229; - await ensurePortAvailable(inspectPort); - } else { - inspectPort = await findAvailablePort(Number.isFinite(inspectorParsed) ? inspectorParsed : 9229); - } - process.env.INSPECT_PORT = inspectPort.toString(); + // const inspectorParsed = process.env.INSPECT_PORT ? Number.parseInt(process.env.INSPECT_PORT, 10) : undefined; + // let inspectPort: number; + // if (isDefaultRepo) { + // inspectPort = Number.isFinite(inspectorParsed) ? inspectorParsed! : 9229; + // await ensurePortAvailable(inspectPort); + // } else { + // inspectPort = await findAvailablePort(Number.isFinite(inspectorParsed) ? inspectorParsed : 9229); + // } + // process.env.INSPECT_PORT = inspectPort.toString(); // 4. Set environment variables that depend on the resolved ports. const apiBaseUrl = `http://localhost:${backendPort}/api/`; @@ -100,14 +114,14 @@ async function main(): Promise { logger.info(`[start-local] using env file ${envFilePath}`); } logger.info(`[start-local] backend listening on ${backendPort}`); - logger.info(`[start-local] inspector listening on ${inspectPort}`); + // logger.info(`[start-local] inspector listening on ${inspectPort}`); // 5. Attempt to open the inspector in a browser. - try { - open(inspectPort, '0.0.0.0', false); - } catch (error) { - logger.warn(error, `[start-local] failed to open inspector on ${inspectPort}`); - } + // try { + // open(inspectPort, '0.0.0.0', false); + // } catch (error) { + // logger.warn(error, `[start-local] failed to open inspector on ${inspectPort}`); + // } // 6. Write runtime metadata for other processes to consume. // This allows the frontend dev server to know which port the backend is running on. @@ -115,7 +129,7 @@ async function main(): Promise { writeRuntimeMetadata(runtimeMetadataPath, { envFilePath, backendPort, - inspectPort, + // inspectPort, }); // 7. Start the server by requiring the main application entry point. @@ -236,20 +250,6 @@ function writeRuntimeMetadata(targetPath: string, data: Record) writeFileSync(targetPath, JSON.stringify({ ...data, updatedAt: new Date().toISOString() }, null, 2)); } -type ServerFactory = () => NetServer; - -let serverFactory: ServerFactory = () => createServer(); - -/** - * Overrides the net server factory used when probing ports. - * This is primarily a testing utility to allow mocking of `net.createServer` - * in environments where opening real sockets is not possible or desired. - * @param factory A function that returns a `net.Server` instance, or null to reset. - */ -function setServerFactory(factory: ServerFactory | null): void { - serverFactory = factory ?? (() => createServer()); -} - /** * Attempts to find a free TCP port. * It first checks the `preferred` port and a number of subsequent ports (`attempts`).