clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

feat/141-effect-library
lofi / package / tooling / environment.ts
environment.ts 7.0K
1import { normalizeDeploymentBase } from "./base-path.ts";23export const clientEnvironmentNames = ["JAZZ_APP_ID", "JAZZ_SERVER_URL"] as const;4export const serverEnvironmentNames = ["JAZZ_ADMIN_SECRET", "BACKEND_SECRET"] as const;5export const cspEnvironmentNames = [6 "LOFI_CSP",7 "LOFI_CSP_SCRIPT_SRC",8 "LOFI_CSP_STYLE_SRC",9 "LOFI_CSP_CONNECT_SRC",10 "LOFI_CSP_DIRECTIVES",11] as const;12export const deploymentEnvironmentNames = ["LOFI_BASE_PATH", ...cspEnvironmentNames] as const;13export const environmentNames = [14 ...clientEnvironmentNames,15 ...serverEnvironmentNames,16 ...deploymentEnvironmentNames,17] as const;1819interface EnvironmentResultBase {20 presentClientNames: string[];21 presentServerNames: string[];22 basePath: string;23 /** Content-Security-Policy tuning passed through to the build verbatim. */24 csp: Record<string, string>;25 warnings: string[];26}2728export type EnvironmentValidation =29 & EnvironmentResultBase30 & (31 | {32 ok: true;33 mode: "local-only";34 client: Record<string, never>;35 }36 | {37 ok: true;38 mode: "cloud-configured";39 client: { JAZZ_APP_ID: string; JAZZ_SERVER_URL: string };40 }41 | {42 ok: false;43 mode: "invalid";44 errors: string[];45 }46 );4748function isPresent(env: Readonly<Record<string, string>>, name: string): boolean {49 return (env[name] ?? "").trim().length > 0;50}5152export function validateEnvironment(53 env: Readonly<Record<string, string>>,54): EnvironmentValidation {55 const presentClientNames = clientEnvironmentNames.filter((name) => isPresent(env, name));56 const presentServerNames = serverEnvironmentNames.filter((name) => isPresent(env, name));57 const errors: string[] = [];58 const warnings: string[] = [];59 let basePath = "/";6061 try {62 basePath = normalizeDeploymentBase(env.LOFI_BASE_PATH);63 } catch (error) {64 errors.push(error instanceof Error ? error.message : String(error));65 }6667 const csp: Record<string, string> = {};68 for (const name of cspEnvironmentNames) {69 if (isPresent(env, name)) csp[name] = env[name].trim();70 }71 if (csp.LOFI_CSP !== undefined && csp.LOFI_CSP !== "off") {72 errors.push(73 'LOFI_CSP only accepts "off" (the default policy is on); unset it or set LOFI_CSP=off.',74 );75 }7677 if (78 presentClientNames.length > 0 && presentClientNames.length !== clientEnvironmentNames.length79 ) {80 const missing = clientEnvironmentNames.filter((name) => !isPresent(env, name));81 errors.push(82 `Cloud sync cannot start because configuration is incomplete; set ${83 missing.join(", ")84 } or remove the partial JAZZ_APP_ID/JAZZ_SERVER_URL pair to run local-only.`,85 );86 }8788 if (isPresent(env, "JAZZ_SERVER_URL")) {89 try {90 const url = new URL(env.JAZZ_SERVER_URL);91 if (!new Set(["http:", "https:"]).has(url.protocol)) {92 errors.push(93 "Cloud sync cannot start because JAZZ_SERVER_URL must use http or https; correct the URL or remove the cloud configuration to run local-only.",94 );95 }96 } catch {97 errors.push(98 "Cloud sync cannot start because JAZZ_SERVER_URL is not a valid URL; correct it or remove the cloud configuration to run local-only.",99 );100 }101 }102103 const base: EnvironmentResultBase = {104 presentClientNames: [...presentClientNames],105 presentServerNames: [...presentServerNames],106 basePath,107 csp,108 warnings,109 };110 if (errors.length > 0) return { ...base, ok: false, mode: "invalid", errors };111 if (presentClientNames.length === 0) {112 if (presentServerNames.length > 0) {113 warnings.push(114 "Server-only credentials are present without cloud client configuration; they remain isolated and unused by the client.",115 );116 }117 return { ...base, ok: true, mode: "local-only", client: {} };118 }119 return {120 ...base,121 ok: true,122 mode: "cloud-configured",123 client: {124 JAZZ_APP_ID: env.JAZZ_APP_ID.trim(),125 JAZZ_SERVER_URL: env.JAZZ_SERVER_URL.trim(),126 },127 };128}129130export function parseDotenv(source: string): Record<string, string> {131 const parsed: Record<string, string> = {};132 for (const originalLine of source.split(/\r?\n/)) {133 let line = originalLine.trim();134 if (line.length === 0 || line.startsWith("#")) continue;135 if (line.startsWith("export ")) line = line.slice("export ".length).trimStart();136 const separator = line.indexOf("=");137 if (separator < 1) continue;138 const name = line.slice(0, separator).trim();139 if (!environmentNames.includes(name as (typeof environmentNames)[number])) continue;140 let value = line.slice(separator + 1).trim();141 if (142 value.length >= 2 &&143 ((value.startsWith('"') && value.endsWith('"')) ||144 (value.startsWith("'") && value.endsWith("'")))145 ) {146 value = value.slice(1, -1);147 }148 parsed[name] = value;149 }150 return parsed;151}152153export async function loadEnvironment(154 path = ".env",155 processLookup: (name: string) => string | undefined = (name) => Deno.env.get(name),156): Promise<Record<string, string>> {157 let fileEnvironment: Record<string, string> = {};158 try {159 fileEnvironment = parseDotenv(await Deno.readTextFile(path));160 } catch (error) {161 if (!(error instanceof Deno.errors.NotFound)) throw error;162 }163 return Object.fromEntries(164 environmentNames.map((name) => [name, processLookup(name) ?? fileEnvironment[name] ?? ""]),165 );166}167168const childEnvironmentAllowlist = [169 "CI",170 "DENO_DIR",171 "HOME",172 "LANG",173 "LC_ALL",174 "NIX_SSL_CERT_FILE",175 "NO_PROXY",176 "PATH",177 "PLAYWRIGHT_BROWSERS_PATH",178 "SSL_CERT_FILE",179 "TEMP",180 "TMP",181 "TMPDIR",182 // Windows process bootstrap. Children run with a cleared environment, and183 // without these Windows cannot load system DLLs, resolve executables, or184 // locate per-user state.185 "APPDATA",186 "COMSPEC",187 "HOMEDRIVE",188 "HOMEPATH",189 "LOCALAPPDATA",190 "PATHEXT",191 "PROGRAMDATA",192 "SYSTEMDRIVE",193 "SYSTEMROOT",194 "USERPROFILE",195 "WINDIR",196] as const;197198const childEnvironmentAllowedUpper = new Set<string>(199 childEnvironmentAllowlist.map((name) => name.toUpperCase()),200);201202export function childEnvironment(203 validation: Exclude<EnvironmentValidation, { ok: false }>,204 source: Record<string, string> = Deno.env.toObject(),205): Record<string, string> {206 const environment: Record<string, string> = {};207 // Windows environment names are case-insensitive and arrive with arbitrary208 // casing ("Path", "SystemRoot"); match case-insensitively and preserve the209 // source casing so the child sees the variables the platform expects.210 for (const [name, value] of Object.entries(source)) {211 if (value && childEnvironmentAllowedUpper.has(name.toUpperCase())) {212 environment[name] = value;213 }214 }215 if (validation.mode === "cloud-configured") {216 environment.JAZZ_APP_ID = validation.client.JAZZ_APP_ID;217 environment.JAZZ_SERVER_URL = validation.client.JAZZ_SERVER_URL;218 } else {219 environment.JAZZ_APP_ID = "";220 environment.JAZZ_SERVER_URL = "";221 }222 environment.LOFI_BASE_PATH = validation.basePath;223 for (const [name, value] of Object.entries(validation.csp)) environment[name] = value;224 environment.JAZZ_ADMIN_SECRET = "";225 environment.BACKEND_SECRET = "";226 return environment;227}