Skip to content

Commit 3fd4efa

Browse files
Dynamic port allocation for server and frontend (for worktrees etc)
1 parent 87991f9 commit 3fd4efa

File tree

6 files changed

+489
-12
lines changed

6 files changed

+489
-12
lines changed

frontend/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,11 @@
99
"ng": "ng",
1010
"pnpm": " ng config -g cli.packageManager pnpm",
1111
"start": "ng serve --host 0.0.0.0",
12-
"start:local": "pnpm run env:local && pnpm run start",
12+
"start:local": "node scripts/start-local.js",
1313
"build": " pnpm run env:local && ng build",
1414
"build:stage": "pnpm run env:stage && ng build",
1515
"build:prod": " pnpm run env:prod && ng build --configuration production",
16-
"env:local": "node --env-file=../variables/local.env scripts/env.js",
16+
"env:local": "node scripts/env.js",
1717
"env:test": " node --env-file=../variables/test.env scripts/env.js",
1818
"env:stage": "node --env-file=../variables/stage.env scripts/env.js",
1919
"env:prod": " node --env-file=../variables/prod.env scripts/env.js",

frontend/scripts/env-utils.js

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
const fs = require('fs');
2+
const path = require('path');
3+
4+
function resolveEnvFilePath() {
5+
const cwd = process.cwd();
6+
const envFile = process.env.ENV_FILE;
7+
if (envFile) {
8+
const candidate = path.isAbsolute(envFile) ? envFile : path.resolve(cwd, envFile);
9+
if (fs.existsSync(candidate)) return candidate;
10+
}
11+
const localEnv = path.resolve(cwd, '../variables/local.env');
12+
if (fs.existsSync(localEnv)) return localEnv;
13+
if (process.env.TYPEDAI_HOME) {
14+
const typedAiEnv = path.resolve(process.env.TYPEDAI_HOME, 'variables/local.env');
15+
if (fs.existsSync(typedAiEnv)) return typedAiEnv;
16+
}
17+
return null;
18+
}
19+
20+
function loadEnvFile(filePath) {
21+
if (!filePath) return {};
22+
const contents = fs.readFileSync(filePath, 'utf8');
23+
const lines = contents.split(/\r?\n/);
24+
const env = {};
25+
for (const rawLine of lines) {
26+
const line = rawLine.trim();
27+
if (!line || line.startsWith('#')) continue;
28+
const equalIndex = line.indexOf('=');
29+
if (equalIndex <= 0) continue;
30+
const key = line.substring(0, equalIndex).trim().replace(/^export\s+/, '');
31+
if (!key) continue;
32+
let value = line.substring(equalIndex + 1).trim();
33+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
34+
value = value.slice(1, -1);
35+
}
36+
env[key] = value.replace(/\\n/g, '\n');
37+
}
38+
return env;
39+
}
40+
41+
function hydrateProcessEnv() {
42+
const envPath = resolveEnvFilePath();
43+
if (!envPath) {
44+
console.warn('No environment file found; relying on existing environment variables.');
45+
return;
46+
}
47+
const vars = loadEnvFile(envPath);
48+
for (const [key, value] of Object.entries(vars)) {
49+
if (process.env[key] === undefined) process.env[key] = value;
50+
}
51+
}
52+
53+
function determineBackendPort() {
54+
if (process.env.BACKEND_PORT) return process.env.BACKEND_PORT;
55+
if (process.env.PORT) return process.env.PORT;
56+
try {
57+
const runtimePath = path.resolve(process.cwd(), '../.typedai/runtime/backend.json');
58+
if (fs.existsSync(runtimePath)) {
59+
const { backendPort } = JSON.parse(fs.readFileSync(runtimePath, 'utf8'));
60+
if (backendPort) return String(backendPort);
61+
}
62+
} catch (error) {
63+
console.warn('Unable to read backend runtime metadata.', error);
64+
}
65+
return null;
66+
}
67+
68+
function determineFrontendPort() {
69+
if (process.env.FRONTEND_PORT) return process.env.FRONTEND_PORT;
70+
if (process.env.UI_PORT) return process.env.UI_PORT;
71+
if (process.env.UI_URL) {
72+
const match = process.env.UI_URL.match(/:(\d+)/);
73+
if (match) return match[1];
74+
}
75+
return null;
76+
}
77+
78+
module.exports = {
79+
resolveEnvFilePath,
80+
loadEnvFile,
81+
hydrateProcessEnv,
82+
determineBackendPort,
83+
determineFrontendPort,
84+
};

frontend/scripts/env.js

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,32 @@
1-
const fs = require('fs');
21
const path = require('path');
2+
const fs = require('fs');
3+
const {
4+
hydrateProcessEnv,
5+
determineBackendPort,
6+
determineFrontendPort,
7+
} = require('./env-utils');
38

49
function generateEnvironmentFile() {
10+
hydrateProcessEnv();
11+
12+
const backendPort = determineBackendPort();
13+
const resolvedApiBase = process.env.API_BASE_URL || (backendPort ? `http://localhost:${backendPort}/api/` : 'http://localhost:3000/api/');
14+
const frontPort = determineFrontendPort();
15+
const resolvedUiUrl = process.env.UI_URL || `http://localhost:${frontPort ?? '4200'}/`;
16+
517
const envVars = {
618
version: process.env.npm_package_version,
7-
API_BASE_URL: process.env.API_BASE_URL,
19+
API_BASE_URL: resolvedApiBase,
820
GCLOUD_PROJECT: process.env.GCLOUD_PROJECT,
921
DATABASE_NAME: process.env.DATABASE_NAME,
1022
DATABASE_TYPE: process.env.DATABASE_TYPE,
1123
AUTH: process.env.AUTH,
1224
MODULES: process.env.MODULES,
1325
};
14-
console.log(envVars)
15-
for ([k,v] of Object.entries(envVars)) {
16-
if (!v) console.info(`No value provided for ${k}`);
17-
}
1826

19-
const environmentFile = `// This file is auto-generated by ${__filename}
20-
export const env = ${JSON.stringify(envVars, null, 2)};
21-
`;
27+
console.log('[frontend env] configuration', { API_BASE_URL: envVars.API_BASE_URL, UI_URL: resolvedUiUrl });
28+
29+
const environmentFile = `// This file is auto-generated by ${__filename}\nexport const env = ${JSON.stringify(envVars, null, 2)};\n`;
2230

2331
const targetPath = path.join(__dirname, '../src/environments/.env.ts');
2432

frontend/scripts/start-local.js

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
const { spawn } = require('child_process');
2+
const fs = require('fs');
3+
const path = require('path');
4+
const net = require('net');
5+
const {
6+
hydrateProcessEnv,
7+
determineBackendPort,
8+
determineFrontendPort,
9+
resolveEnvFilePath,
10+
loadEnvFile,
11+
} = require('./env-utils');
12+
13+
function findAvailablePort(preferred, attempts = 20) {
14+
const ports = [];
15+
if (preferred && Number(preferred) > 0) {
16+
const base = Number(preferred);
17+
for (let i = 0; i < attempts; i += 1) ports.push(base + i);
18+
}
19+
ports.push(0);
20+
21+
return new Promise((resolve, reject) => {
22+
const tryNext = () => {
23+
if (ports.length === 0) {
24+
reject(new Error('Unable to find available port for frontend dev server.'));
25+
return;
26+
}
27+
const port = ports.shift();
28+
const server = net.createServer();
29+
server.once('error', () => {
30+
server.close();
31+
tryNext();
32+
});
33+
server.listen({ port, host: '0.0.0.0', exclusive: true }, () => {
34+
const address = server.address();
35+
server.close(() => {
36+
if (address && typeof address === 'object') {
37+
resolve(address.port);
38+
} else {
39+
resolve(port);
40+
}
41+
});
42+
});
43+
};
44+
tryNext();
45+
});
46+
}
47+
48+
function ensurePortAvailable(port) {
49+
return new Promise((resolve, reject) => {
50+
const server = net.createServer();
51+
server.once('error', (error) => {
52+
server.close();
53+
reject(new Error(`Port ${port} is unavailable: ${error.message}`));
54+
});
55+
server.listen({ port, host: '0.0.0.0', exclusive: true }, () => {
56+
server.close(resolve);
57+
});
58+
});
59+
}
60+
61+
function applyEnvFile(filePath) {
62+
if (!filePath || !fs.existsSync(filePath)) return;
63+
const vars = loadEnvFile(filePath);
64+
for (const [key, value] of Object.entries(vars)) {
65+
if (process.env[key] === undefined) process.env[key] = value;
66+
}
67+
}
68+
69+
function writeRuntimeMetadata(filePath, data) {
70+
const dir = path.dirname(filePath);
71+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
72+
fs.writeFileSync(filePath, JSON.stringify({ ...data, updatedAt: new Date().toISOString() }, null, 2));
73+
}
74+
75+
async function main() {
76+
hydrateProcessEnv();
77+
applyEnvFile(resolveEnvFilePath());
78+
79+
const backendPort = determineBackendPort();
80+
const preferredFrontendPort = determineFrontendPort();
81+
const repoRoot = path.resolve(process.cwd(), '..');
82+
const typedAiHome = process.env.TYPEDAI_HOME ? path.resolve(process.env.TYPEDAI_HOME) : null;
83+
const isDefaultRepo = typedAiHome ? repoRoot === typedAiHome : false;
84+
85+
let frontendPort;
86+
if (isDefaultRepo) {
87+
frontendPort = 4200;
88+
await ensurePortAvailable(frontendPort);
89+
} else {
90+
frontendPort = await findAvailablePort(preferredFrontendPort ? Number(preferredFrontendPort) : 4200);
91+
}
92+
93+
process.env.FRONTEND_PORT = String(frontendPort);
94+
process.env.UI_URL = `http://localhost:${frontendPort}/`;
95+
if (!process.env.API_BASE_URL && backendPort) {
96+
process.env.API_BASE_URL = `http://localhost:${backendPort}/api/`;
97+
}
98+
99+
console.log('[frontend start] backend port:', backendPort || 'unknown');
100+
console.log('[frontend start] frontend port:', frontendPort);
101+
102+
// Generate Angular runtime env file with the resolved variables.
103+
require('./env.js');
104+
105+
writeRuntimeMetadata(
106+
path.resolve(process.cwd(), '../.typedai/runtime/frontend.json'),
107+
{
108+
backendPort: backendPort ? Number(backendPort) : undefined,
109+
frontendPort,
110+
},
111+
);
112+
113+
const ngArgs = ['serve', '--host', '0.0.0.0', '--port', String(frontendPort)];
114+
const child = spawn('ng', ngArgs, { stdio: 'inherit', shell: process.platform === 'win32' });
115+
116+
child.on('exit', (code, signal) => {
117+
if (signal) process.kill(process.pid, signal);
118+
process.exit(typeof code === 'number' ? code : 0);
119+
});
120+
121+
process.on('SIGINT', () => child.kill('SIGINT'));
122+
process.on('SIGTERM', () => child.kill('SIGTERM'));
123+
}
124+
125+
main().catch((error) => {
126+
console.error('[frontend start] failed to launch Angular dev server:', error);
127+
process.exit(1);
128+
});

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
"initTiktokenizer": "node --env-file=variables/local.env -r esbuild-register src/initTiktokenizer.ts",
4444
"functionSchemas": "node --env-file=variables/local.env -r esbuild-register src/functionSchema/generateFunctionSchemas.ts",
4545
"start": " node -r ts-node/register --env-file=variables/.env src/index.ts",
46-
"start:local": "node -r ts-node/register --env-file=variables/local.env --inspect=0.0.0.0:9229 src/index.ts",
46+
"start:local": "node -r esbuild-register src/cli/startLocal.ts",
4747
"emulators": "gcloud emulators firestore start --host-port=127.0.0.1:8243",
4848
"test": " pnpm run test:unit && pnpm run test:db",
4949
"test:unit": " node --env-file=variables/test.env ./node_modules/mocha/bin/mocha -r esbuild-register -r \"./src/test/testSetup.ts\" \"src/**/*.test.[jt]s\" --exclude \"src/modules/{firestore,mongo,postgres}/*.test.ts\" --timeout 10000",

0 commit comments

Comments
 (0)