171 lines
4.7 KiB
TypeScript
171 lines
4.7 KiB
TypeScript
import { spawn } from 'node:child_process';
|
|
import { mkdirSync, readFileSync } from 'node:fs';
|
|
import { Database } from 'bun:sqlite';
|
|
import { createServer } from 'node:net';
|
|
import { dirname, join } from 'node:path';
|
|
import { buildLocalDevConfig, resolveSqlitePath } from './dev-env';
|
|
|
|
type DrizzleJournal = {
|
|
entries: Array<{ tag: string }>;
|
|
};
|
|
|
|
type ExitResult = {
|
|
code: number | null;
|
|
signal: NodeJS.Signals | null;
|
|
};
|
|
|
|
function trim(value: string | undefined) {
|
|
const candidate = value?.trim();
|
|
return candidate ? candidate : undefined;
|
|
}
|
|
|
|
async function isPortAvailable(port: number, host: string) {
|
|
return await new Promise<boolean>((resolve) => {
|
|
const server = createServer();
|
|
|
|
server.once('error', () => resolve(false));
|
|
server.listen(port, host, () => {
|
|
server.close(() => resolve(true));
|
|
});
|
|
});
|
|
}
|
|
|
|
async function pickLocalPort(host: string) {
|
|
const candidatePorts = [3000, 3001, 3002, 3100, 3200, 3300];
|
|
|
|
for (const port of candidatePorts) {
|
|
if (await isPortAvailable(port, host)) {
|
|
return String(port);
|
|
}
|
|
}
|
|
|
|
throw new Error(`Unable to find an open local dev port from: ${candidatePorts.join(', ')}`);
|
|
}
|
|
|
|
function hasTable(database: Database, tableName: string) {
|
|
const row = database
|
|
.query('SELECT name FROM sqlite_master WHERE type = ? AND name = ? LIMIT 1')
|
|
.get('table', tableName) as { name: string } | null;
|
|
|
|
return row !== null;
|
|
}
|
|
|
|
function readMigrationFiles() {
|
|
const journal = JSON.parse(
|
|
readFileSync(join(process.cwd(), 'drizzle', 'meta', '_journal.json'), 'utf8')
|
|
) as DrizzleJournal;
|
|
|
|
return journal.entries.map((entry) => join(process.cwd(), 'drizzle', `${entry.tag}.sql`));
|
|
}
|
|
|
|
function bootstrapFreshDatabase(databaseUrl: string) {
|
|
const databasePath = resolveSqlitePath(databaseUrl);
|
|
if (!databasePath || databasePath === ':memory:') {
|
|
return false;
|
|
}
|
|
|
|
mkdirSync(dirname(databasePath), { recursive: true });
|
|
|
|
const database = new Database(databasePath, { create: true });
|
|
|
|
try {
|
|
database.exec('PRAGMA foreign_keys = ON;');
|
|
|
|
if (hasTable(database, 'user')) {
|
|
return false;
|
|
}
|
|
|
|
for (const migrationFile of readMigrationFiles()) {
|
|
database.exec(readFileSync(migrationFile, 'utf8'));
|
|
}
|
|
|
|
return true;
|
|
} finally {
|
|
database.close();
|
|
}
|
|
}
|
|
|
|
function exitFromResult(result: ExitResult) {
|
|
if (result.signal) {
|
|
process.exit(result.signal === 'SIGINT' || result.signal === 'SIGTERM' ? 0 : 1);
|
|
return;
|
|
}
|
|
|
|
process.exit(result.code ?? 0);
|
|
}
|
|
|
|
const explicitPort = trim(process.env.PORT) || trim(process.env.APP_PORT);
|
|
const bindHost = trim(process.env.HOSTNAME) || trim(process.env.HOST) || '127.0.0.1';
|
|
const resolvedPort = explicitPort || await pickLocalPort(bindHost);
|
|
const config = buildLocalDevConfig({
|
|
...process.env,
|
|
HOSTNAME: bindHost,
|
|
PORT: resolvedPort
|
|
});
|
|
const env = {
|
|
...config.env
|
|
} as NodeJS.ProcessEnv;
|
|
|
|
delete env.NO_COLOR;
|
|
|
|
const databasePath = resolveSqlitePath(env.DATABASE_URL ?? '');
|
|
if (databasePath && databasePath !== ':memory:') {
|
|
mkdirSync(dirname(databasePath), { recursive: true });
|
|
}
|
|
|
|
mkdirSync(env.WORKFLOW_LOCAL_DATA_DIR ?? '.workflow-data', { recursive: true });
|
|
|
|
const initializedDatabase = bootstrapFreshDatabase(env.DATABASE_URL ?? '');
|
|
|
|
console.info(`[dev] local origin ${config.publicOrigin}`);
|
|
console.info(`[dev] sqlite ${env.DATABASE_URL}`);
|
|
console.info(`[dev] workflow ${env.WORKFLOW_TARGET_WORLD} (${env.WORKFLOW_LOCAL_DATA_DIR})`);
|
|
if (!explicitPort && resolvedPort !== '3000') {
|
|
console.info(`[dev] port 3000 is busy, using http://localhost:${resolvedPort} instead`);
|
|
}
|
|
if (initializedDatabase) {
|
|
console.info('[dev] initialized the local SQLite schema from drizzle SQL files');
|
|
}
|
|
|
|
if (config.overrides.authOriginChanged) {
|
|
console.info('[dev] forcing Better Auth origin/trusted origins to the local origin');
|
|
}
|
|
|
|
if (config.overrides.apiBaseChanged) {
|
|
console.info('[dev] forcing NEXT_PUBLIC_API_URL to same-origin for local dev');
|
|
}
|
|
|
|
if (config.overrides.databaseChanged) {
|
|
console.info('[dev] using a local SQLite database instead of the deployment path');
|
|
}
|
|
|
|
if (config.overrides.workflowChanged) {
|
|
console.info('[dev] forcing Workflow to the local runtime for local dev');
|
|
}
|
|
|
|
if (config.overrides.secretFallbackUsed) {
|
|
console.info('[dev] using the built-in local Better Auth secret because BETTER_AUTH_SECRET is unset or still a placeholder');
|
|
}
|
|
|
|
const child = spawn(
|
|
'bun',
|
|
['--bun', 'next', 'dev', '--turbopack', '--hostname', config.bindHost, '--port', config.port],
|
|
{
|
|
env,
|
|
stdio: 'inherit'
|
|
}
|
|
);
|
|
|
|
function forwardSignal(signal: NodeJS.Signals) {
|
|
if (!child.killed) {
|
|
child.kill(signal);
|
|
}
|
|
}
|
|
|
|
process.on('SIGINT', () => forwardSignal('SIGINT'));
|
|
process.on('SIGTERM', () => forwardSignal('SIGTERM'));
|
|
|
|
child.on('exit', (code, signal) => {
|
|
exitFromResult({ code, signal });
|
|
});
|