Files
Neon-Desk/scripts/dev-env.ts

179 lines
5.5 KiB
TypeScript

const DEFAULT_BIND_HOST = '127.0.0.1';
const DEFAULT_DATABASE_URL = 'file:data/fiscal.sqlite';
const DEFAULT_PORT = '3000';
const DEFAULT_PUBLIC_HOST = 'localhost';
const DEFAULT_SEC_USER_AGENT = 'Fiscal Clone <support@fiscal.local>';
const DEFAULT_WORKFLOW_LOCAL_DATA_DIR = '.workflow-data';
const DEFAULT_WORKFLOW_LOCAL_QUEUE_CONCURRENCY = '100';
const PLACEHOLDER_SECRET = 'replace-with-a-long-random-secret';
export const LOCAL_DEV_SECRET = 'fiscal-local-dev-secret-fiscal-local-dev-secret';
type EnvMap = Record<string, string | undefined>;
type LocalDevOverrideSummary = {
apiBaseChanged: boolean;
authOriginChanged: boolean;
databaseChanged: boolean;
secretFallbackUsed: boolean;
workflowChanged: boolean;
};
export type LocalDevConfig = {
bindHost: string;
env: EnvMap;
overrides: LocalDevOverrideSummary;
port: string;
publicOrigin: string;
};
function trim(value: string | undefined) {
const candidate = value?.trim();
return candidate ? candidate : undefined;
}
function parseCsvList(value: string | undefined) {
return (value ?? '')
.split(',')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
}
function toUniqueList(values: string[]) {
return Array.from(new Set(values));
}
function isLoopbackHostname(hostname: string) {
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
}
function replaceOriginHostname(origin: string, hostname: string) {
const url = new URL(origin);
url.hostname = hostname;
url.hash = '';
url.search = '';
const pathName = url.pathname.replace(/\/$/, '');
return `${url.origin}${pathName === '/' ? '' : pathName}`;
}
function buildTrustedOrigins(publicOrigin: string, configuredOrigins: string | undefined) {
const trustedOrigins = [publicOrigin];
const publicOriginUrl = new URL(publicOrigin);
if (isLoopbackHostname(publicOriginUrl.hostname)) {
trustedOrigins.push(replaceOriginHostname(publicOrigin, 'localhost'));
trustedOrigins.push(replaceOriginHostname(publicOrigin, '127.0.0.1'));
}
trustedOrigins.push(...parseCsvList(configuredOrigins));
return toUniqueList(trustedOrigins).join(',');
}
function coercePort(port: string | undefined) {
const parsed = Number.parseInt(port ?? '', 10);
if (Number.isInteger(parsed) && parsed > 0 && parsed <= 65535) {
return String(parsed);
}
return DEFAULT_PORT;
}
function normalizeOrigin(origin: string) {
const url = new URL(origin);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error(`DEV_PUBLIC_ORIGIN must use http or https. Received: ${origin}`);
}
url.hash = '';
url.search = '';
const pathName = url.pathname.replace(/\/$/, '');
return `${url.origin}${pathName === '/' ? '' : pathName}`;
}
function buildPublicOrigin(sourceEnv: EnvMap, port: string) {
const explicitOrigin = trim(sourceEnv.DEV_PUBLIC_ORIGIN);
if (explicitOrigin) {
return normalizeOrigin(explicitOrigin);
}
const publicHost = trim(sourceEnv.DEV_PUBLIC_HOST) || DEFAULT_PUBLIC_HOST;
return normalizeOrigin(`http://${publicHost}:${port}`);
}
function isSqliteLikeDatabase(databaseUrl: string) {
return databaseUrl === ':memory:' || databaseUrl.startsWith('file:') || !databaseUrl.includes('://');
}
export function resolveSqlitePath(databaseUrl: string) {
let databasePath = databaseUrl.startsWith('file:')
? databaseUrl.slice(5)
: databaseUrl;
if (databasePath.startsWith('///')) {
databasePath = databasePath.slice(2);
}
return databasePath;
}
function shouldUseDefaultDatabase(databaseUrl: string | undefined) {
if (!databaseUrl) {
return true;
}
if (!isSqliteLikeDatabase(databaseUrl)) {
return true;
}
const path = resolveSqlitePath(databaseUrl);
return path.startsWith('/app/');
}
export function buildLocalDevConfig(sourceEnv: EnvMap = process.env): LocalDevConfig {
const bindHost = trim(sourceEnv.HOSTNAME) || trim(sourceEnv.HOST) || DEFAULT_BIND_HOST;
const port = coercePort(trim(sourceEnv.PORT) || trim(sourceEnv.APP_PORT));
const publicOrigin = buildPublicOrigin(sourceEnv, port);
const databaseUrl = shouldUseDefaultDatabase(trim(sourceEnv.DATABASE_URL))
? DEFAULT_DATABASE_URL
: trim(sourceEnv.DATABASE_URL) ?? DEFAULT_DATABASE_URL;
const secret = trim(sourceEnv.BETTER_AUTH_SECRET);
const trustedOrigins = buildTrustedOrigins(publicOrigin, sourceEnv.BETTER_AUTH_TRUSTED_ORIGINS);
const env: EnvMap = {
...sourceEnv,
BETTER_AUTH_BASE_URL: publicOrigin,
BETTER_AUTH_SECRET: !secret || secret === PLACEHOLDER_SECRET
? LOCAL_DEV_SECRET
: secret,
BETTER_AUTH_TRUSTED_ORIGINS: trustedOrigins,
DATABASE_URL: databaseUrl,
HOSTNAME: bindHost,
NEXT_PUBLIC_API_URL: '',
PORT: port,
SEC_USER_AGENT: trim(sourceEnv.SEC_USER_AGENT) || DEFAULT_SEC_USER_AGENT,
WORKFLOW_LOCAL_DATA_DIR: trim(sourceEnv.WORKFLOW_LOCAL_DATA_DIR) || DEFAULT_WORKFLOW_LOCAL_DATA_DIR,
WORKFLOW_LOCAL_QUEUE_CONCURRENCY:
trim(sourceEnv.WORKFLOW_LOCAL_QUEUE_CONCURRENCY) || DEFAULT_WORKFLOW_LOCAL_QUEUE_CONCURRENCY,
WORKFLOW_TARGET_WORLD: 'local'
};
return {
bindHost,
env,
overrides: {
apiBaseChanged: trim(sourceEnv.NEXT_PUBLIC_API_URL) !== undefined,
authOriginChanged: trim(sourceEnv.BETTER_AUTH_BASE_URL) !== publicOrigin
|| trim(sourceEnv.BETTER_AUTH_URL) !== undefined,
databaseChanged: databaseUrl !== trim(sourceEnv.DATABASE_URL),
secretFallbackUsed: env.BETTER_AUTH_SECRET === LOCAL_DEV_SECRET,
workflowChanged: trim(sourceEnv.WORKFLOW_TARGET_WORLD) !== 'local'
},
port,
publicOrigin
};
}