364 lines
10 KiB
JavaScript
364 lines
10 KiB
JavaScript
import {spawn} from 'node:child_process';
|
|
import {existsSync, readFileSync} from 'node:fs';
|
|
import net from 'node:net';
|
|
import path from 'node:path';
|
|
import {fileURLToPath, pathToFileURL} from 'node:url';
|
|
|
|
const repoRoot = fileURLToPath(new URL('../', import.meta.url));
|
|
const serverRoot = fileURLToPath(new URL('../server-node/', import.meta.url));
|
|
const viteCliPath = fileURLToPath(new URL('./vite-cli.mjs', import.meta.url));
|
|
const serverTsxCliPath = fileURLToPath(
|
|
new URL('../server-node/node_modules/tsx/dist/cli.mjs', import.meta.url),
|
|
);
|
|
const serverTsxLoaderPath = fileURLToPath(
|
|
new URL('../server-node/node_modules/tsx/dist/loader.mjs', import.meta.url),
|
|
);
|
|
const serverTsxLoaderUrl = pathToFileURL(serverTsxLoaderPath).href;
|
|
const envExamplePath = fileURLToPath(new URL('../.env.example', import.meta.url));
|
|
const envLocalPath = fileURLToPath(new URL('../.env.local', import.meta.url));
|
|
const bundledNodePath = fileURLToPath(
|
|
new URL('../.tools/node-v22.22.2-win-x64/node.exe', import.meta.url),
|
|
);
|
|
const bundledNpmCliPath = fileURLToPath(
|
|
new URL('../.tools/node-v22.22.2-win-x64/node_modules/npm/bin/npm-cli.js', import.meta.url),
|
|
);
|
|
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
const DEFAULT_DEV_DATABASE_URL = 'postgresql://postgres:postgres@127.0.0.1:5432/genarrative';
|
|
const DEV_MEMORY_DATABASE_URL = 'pg-mem://genarrative-dev';
|
|
const DEFAULT_SPACETIME_URI = 'wss://maincloud.spacetimedb.com';
|
|
const DEFAULT_SPACETIME_DATABASE_NAME = 'xushi-p4wfr';
|
|
|
|
function parseEnvContents(contents) {
|
|
return contents
|
|
.split(/\r?\n/u)
|
|
.reduce((envMap, rawLine) => {
|
|
const line = rawLine.trim();
|
|
if (!line || line.startsWith('#')) {
|
|
return envMap;
|
|
}
|
|
|
|
const separatorIndex = line.indexOf('=');
|
|
if (separatorIndex < 0) {
|
|
return envMap;
|
|
}
|
|
|
|
const key = line.slice(0, separatorIndex).trim();
|
|
let value = line.slice(separatorIndex + 1).trim();
|
|
|
|
if (
|
|
(value.startsWith('"') && value.endsWith('"')) ||
|
|
(value.startsWith("'") && value.endsWith("'"))
|
|
) {
|
|
value = value.slice(1, -1);
|
|
}
|
|
|
|
envMap[key] = value;
|
|
return envMap;
|
|
}, {});
|
|
}
|
|
|
|
function readEnvFile(filePath) {
|
|
if (!existsSync(filePath)) {
|
|
return {};
|
|
}
|
|
|
|
return parseEnvContents(readFileSync(filePath, 'utf8'));
|
|
}
|
|
|
|
function resolveDatabaseProbeTarget(databaseUrl) {
|
|
const trimmed = databaseUrl.trim();
|
|
if (!trimmed || !/^postgres(?:ql)?:\/\//u.test(trimmed)) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const url = new URL(trimmed);
|
|
return {
|
|
host: url.hostname === '0.0.0.0' ? '127.0.0.1' : url.hostname,
|
|
port: Number(url.port || 5432),
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function checkTcpReachable(target, timeoutMs = 1500) {
|
|
return new Promise((resolve) => {
|
|
const socket = net.createConnection(target);
|
|
let settled = false;
|
|
|
|
const finish = (result) => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
settled = true;
|
|
socket.destroy();
|
|
resolve(result);
|
|
};
|
|
|
|
socket.setTimeout(timeoutMs);
|
|
socket.once('connect', () => finish(true));
|
|
socket.once('timeout', () => finish(false));
|
|
socket.once('error', () => finish(false));
|
|
});
|
|
}
|
|
|
|
function resolveServerTarget(serverAddr) {
|
|
const trimmed = serverAddr.trim();
|
|
|
|
if (!trimmed) {
|
|
return 'http://127.0.0.1:8081';
|
|
}
|
|
|
|
if (/^https?:\/\//u.test(trimmed)) {
|
|
try {
|
|
const url = new URL(trimmed);
|
|
if (url.hostname === '0.0.0.0') {
|
|
url.hostname = '127.0.0.1';
|
|
}
|
|
return url.toString().replace(/\/$/u, '');
|
|
} catch {
|
|
return trimmed.replace(/\/$/u, '');
|
|
}
|
|
}
|
|
|
|
if (trimmed.startsWith(':')) {
|
|
return `http://127.0.0.1${trimmed}`;
|
|
}
|
|
|
|
if (trimmed.startsWith('0.0.0.0:')) {
|
|
return `http://127.0.0.1:${trimmed.slice('0.0.0.0:'.length)}`;
|
|
}
|
|
|
|
return `http://${trimmed}`;
|
|
}
|
|
|
|
function redactDatabaseUrl(databaseUrl) {
|
|
const trimmed = `${databaseUrl || ''}`.trim();
|
|
|
|
if (!trimmed) {
|
|
return '[missing]';
|
|
}
|
|
|
|
if (trimmed.startsWith('pg-mem://')) {
|
|
return trimmed;
|
|
}
|
|
|
|
try {
|
|
const url = new URL(trimmed);
|
|
const databaseName = url.pathname.replace(/^\/+/u, '') || 'postgres';
|
|
const portSuffix = url.port ? `:${url.port}` : '';
|
|
return `${url.protocol}//${url.hostname}${portSuffix}/${databaseName}`;
|
|
} catch {
|
|
return '[configured]';
|
|
}
|
|
}
|
|
|
|
function resolvePathEnvKey(envMap) {
|
|
return Object.keys(envMap).find((key) => key.toLowerCase() === 'path') || 'PATH';
|
|
}
|
|
|
|
function prependEnvPath(envMap, nextEntry) {
|
|
const pathKey = resolvePathEnvKey(envMap);
|
|
const currentValue = envMap[pathKey] || '';
|
|
const normalizedEntry = path.resolve(nextEntry);
|
|
const segments = currentValue
|
|
.split(path.delimiter)
|
|
.map((entry) => entry.trim())
|
|
.filter(Boolean)
|
|
.filter((entry) => {
|
|
try {
|
|
return path.resolve(entry) !== normalizedEntry;
|
|
} catch {
|
|
return entry !== nextEntry;
|
|
}
|
|
});
|
|
|
|
envMap[pathKey] = [nextEntry, ...segments].join(path.delimiter);
|
|
}
|
|
|
|
const exampleEnv = readEnvFile(envExamplePath);
|
|
const localEnv = readEnvFile(envLocalPath);
|
|
|
|
const mergedEnv = {
|
|
...exampleEnv,
|
|
...localEnv,
|
|
...process.env,
|
|
};
|
|
|
|
const runtimeNodePath = existsSync(bundledNodePath)
|
|
? bundledNodePath
|
|
: process.execPath;
|
|
const runtimeNpmCliPath = existsSync(bundledNpmCliPath)
|
|
? bundledNpmCliPath
|
|
: '';
|
|
const runtimeNodeDir = path.dirname(runtimeNodePath);
|
|
|
|
mergedEnv.PROJECT_ROOT = mergedEnv.PROJECT_ROOT || repoRoot;
|
|
mergedEnv.NODE_SERVER_ADDR = mergedEnv.NODE_SERVER_ADDR || ':8081';
|
|
mergedEnv.NODE_SERVER_TARGET =
|
|
mergedEnv.NODE_SERVER_TARGET || resolveServerTarget(mergedEnv.NODE_SERVER_ADDR);
|
|
mergedEnv.DATABASE_URL =
|
|
mergedEnv.DATABASE_URL || DEFAULT_DEV_DATABASE_URL;
|
|
mergedEnv.VITE_SPACETIME_URI =
|
|
mergedEnv.VITE_SPACETIME_URI || DEFAULT_SPACETIME_URI;
|
|
mergedEnv.VITE_SPACETIME_DATABASE_NAME =
|
|
mergedEnv.VITE_SPACETIME_DATABASE_NAME || DEFAULT_SPACETIME_DATABASE_NAME;
|
|
mergedEnv.VITE_DEV_HOST = mergedEnv.VITE_DEV_HOST || '127.0.0.1';
|
|
prependEnvPath(mergedEnv, runtimeNodeDir);
|
|
mergedEnv.npm_config_scripts_prepend_node_path = 'true';
|
|
|
|
const exampleDatabaseUrl = `${exampleEnv.DATABASE_URL || ''}`.trim();
|
|
const localDatabaseUrl = `${localEnv.DATABASE_URL || ''}`.trim();
|
|
const processDatabaseUrl = `${process.env.DATABASE_URL || ''}`.trim();
|
|
const hasExplicitDatabaseUrl =
|
|
Boolean(processDatabaseUrl) ||
|
|
(Boolean(localDatabaseUrl) && localDatabaseUrl !== exampleDatabaseUrl);
|
|
|
|
if (!hasExplicitDatabaseUrl) {
|
|
const databaseProbeTarget = resolveDatabaseProbeTarget(mergedEnv.DATABASE_URL);
|
|
if (databaseProbeTarget) {
|
|
const isReachable = await checkTcpReachable(databaseProbeTarget);
|
|
if (!isReachable) {
|
|
console.warn(
|
|
`[dev:node] PostgreSQL unavailable at ${databaseProbeTarget.host}:${databaseProbeTarget.port}; falling back to ${DEV_MEMORY_DATABASE_URL} for local dev.`,
|
|
);
|
|
console.warn(
|
|
'[dev:node] Current session will use in-memory persistence only. Set DATABASE_URL in .env.local to restore PostgreSQL-backed runtime data.',
|
|
);
|
|
mergedEnv.DATABASE_URL = DEV_MEMORY_DATABASE_URL;
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log(`[dev:node] PROJECT_ROOT=${mergedEnv.PROJECT_ROOT}`);
|
|
console.log(`[dev:node] NODE_SERVER_ADDR=${mergedEnv.NODE_SERVER_ADDR}`);
|
|
console.log(`[dev:node] NODE_SERVER_TARGET=${mergedEnv.NODE_SERVER_TARGET}`);
|
|
console.log(`[dev:node] DATABASE_URL=${redactDatabaseUrl(mergedEnv.DATABASE_URL)}`);
|
|
console.log(`[dev:node] VITE_SPACETIME_URI=${mergedEnv.VITE_SPACETIME_URI}`);
|
|
console.log(
|
|
`[dev:node] VITE_SPACETIME_DATABASE_NAME=${mergedEnv.VITE_SPACETIME_DATABASE_NAME}`,
|
|
);
|
|
console.log(`[dev:node] VITE_DEV_HOST=${mergedEnv.VITE_DEV_HOST}`);
|
|
console.log(`[dev:node] NODE_RUNTIME=${runtimeNodePath}`);
|
|
|
|
const children = new Set();
|
|
let shuttingDown = false;
|
|
let pendingExitCode = 0;
|
|
|
|
function stopChild(child) {
|
|
if (!child || child.exitCode !== null) {
|
|
return;
|
|
}
|
|
|
|
child.kill('SIGTERM');
|
|
|
|
setTimeout(() => {
|
|
if (child.exitCode === null) {
|
|
child.kill('SIGKILL');
|
|
}
|
|
}, 2000).unref();
|
|
}
|
|
|
|
function stopAllChildren() {
|
|
for (const child of children) {
|
|
stopChild(child);
|
|
}
|
|
}
|
|
|
|
function finalizeExit(code = 0) {
|
|
pendingExitCode = code;
|
|
if (children.size === 0) {
|
|
process.exit(pendingExitCode);
|
|
}
|
|
}
|
|
|
|
function requestShutdown(code = 0) {
|
|
if (!shuttingDown) {
|
|
shuttingDown = true;
|
|
pendingExitCode = code;
|
|
stopAllChildren();
|
|
}
|
|
|
|
finalizeExit(pendingExitCode);
|
|
}
|
|
|
|
function registerChild(name, child, siblingProvider) {
|
|
children.add(child);
|
|
|
|
child.on('error', (error) => {
|
|
console.error(`[dev:node] ${name} failed to start`, error);
|
|
requestShutdown(1);
|
|
});
|
|
|
|
child.on('exit', (code, signal) => {
|
|
children.delete(child);
|
|
|
|
if (!shuttingDown) {
|
|
const resolvedExitCode = code ?? 1;
|
|
const signalSuffix = signal ? ` (${signal})` : '';
|
|
console.error(
|
|
`[dev:node] ${name} exited with code ${resolvedExitCode}${signalSuffix}`,
|
|
);
|
|
|
|
const sibling = siblingProvider();
|
|
if (sibling) {
|
|
stopChild(sibling);
|
|
}
|
|
|
|
requestShutdown(resolvedExitCode);
|
|
return;
|
|
}
|
|
|
|
finalizeExit(pendingExitCode);
|
|
});
|
|
}
|
|
|
|
const serverProcess = existsSync(serverTsxLoaderPath)
|
|
? spawn(runtimeNodePath, ['--watch', '--import', serverTsxLoaderUrl, 'src/server.ts'], {
|
|
cwd: serverRoot,
|
|
env: mergedEnv,
|
|
stdio: 'inherit',
|
|
})
|
|
: existsSync(serverTsxCliPath)
|
|
? spawn(runtimeNodePath, [serverTsxCliPath, 'watch', 'src/server.ts'], {
|
|
cwd: serverRoot,
|
|
env: mergedEnv,
|
|
stdio: 'inherit',
|
|
})
|
|
: runtimeNpmCliPath
|
|
? spawn(runtimeNodePath, [runtimeNpmCliPath, 'run', 'dev'], {
|
|
cwd: serverRoot,
|
|
env: mergedEnv,
|
|
stdio: 'inherit',
|
|
})
|
|
: spawn(npmCommand, ['run', 'dev'], {
|
|
cwd: serverRoot,
|
|
env: mergedEnv,
|
|
shell: process.platform === 'win32',
|
|
stdio: 'inherit',
|
|
});
|
|
|
|
const viteProcess = spawn(
|
|
runtimeNodePath,
|
|
[viteCliPath, '--port=3000', `--host=${mergedEnv.VITE_DEV_HOST}`],
|
|
{
|
|
cwd: repoRoot,
|
|
env: mergedEnv,
|
|
stdio: 'inherit',
|
|
},
|
|
);
|
|
|
|
registerChild('node server', serverProcess, () => viteProcess);
|
|
registerChild('vite dev server', viteProcess, () => serverProcess);
|
|
|
|
process.on('SIGINT', () => {
|
|
console.log('[dev:node] received SIGINT, shutting down...');
|
|
requestShutdown(0);
|
|
});
|
|
|
|
process.on('SIGTERM', () => {
|
|
console.log('[dev:node] received SIGTERM, shutting down...');
|
|
requestShutdown(0);
|
|
});
|