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/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'}/`; diff --git a/src/cli/startLocal.ts b/src/cli/startLocal.ts index 9f9d7de2..a2b60d58 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'; @@ -19,12 +33,28 @@ 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. */ 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 +63,16 @@ 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; + 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; let backendPort: number; if (isDefaultRepo) { @@ -45,20 +81,25 @@ 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(); - 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(); - + // 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(); + + // 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; } @@ -73,21 +114,25 @@ 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}`); - try { - open(inspectPort, '0.0.0.0', false); - } catch (error) { - logger.warn(error, `[start-local] failed to open inspector 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, backendPort, - inspectPort, + // inspectPort, }); + // 7. Start the server by requiring the main application entry point. const require = createRequire(__filename); require('../index'); } @@ -97,6 +142,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 +156,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 +181,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 +221,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 +239,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); @@ -183,25 +250,15 @@ 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. - * Primarily for tests where opening real sockets would fail in a sandbox. - */ -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 +281,13 @@ async function findAvailablePort(preferred?: number, attempts = 20): Promise { try { await tryListen(port); @@ -234,6 +297,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(); 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,