UNPKG

everything-dev

Version:

A consolidated product package for building Module Federation apps with oRPC APIs.

516 lines (514 loc) • 19.7 kB
import { DevGeneratedEnv, DevRuntimeConfig, ServiceDescriptorMap } from "./service-descriptor.mjs"; import { stripAnsi } from "./dev-log-pipeline.mjs"; import { patchManifestFetchForSsrPublicPath } from "./mf.mjs"; import { ShellEnv } from "./env/project-env.mjs"; import { Data, Deferred, Effect, Option, Ref, Schedule, Schema, Stream } from "effect"; import { spawn } from "node:child_process"; import { Readable } from "node:stream"; //#region src/orchestrator.ts const warnOutsideEffect = (...args) => { console.warn(...args); }; const runFetch = (url, init) => fetch(url, init); process.on("unhandledRejection", (reason) => { console.error("[Orchestrator] Unhandled rejection:", reason); }); process.on("uncaughtException", (err) => { console.error("[Orchestrator] Uncaught exception:", err); }); var OrchestratorError = class extends Data.TaggedError("OrchestratorError") { get message() { return `${this.operation}: ${this.detail}`; } }; const omitUndefined = (value) => { if (Array.isArray(value)) return value.map((item) => item === void 0 ? null : omitUndefined(item)); if (value !== null && typeof value === "object") return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== void 0).map(([key, item]) => [key, omitUndefined(item)])); return value; }; const encodeRuntimeConfig = (runtimeConfig) => Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.Json))(omitUndefined(runtimeConfig)).pipe(Effect.mapError((cause) => new OrchestratorError({ operation: "serialize runtime config", detail: cause.message, cause }))); const probeHttpOk = (url, timeoutMs = 400) => Effect.tryPromise({ try: async () => { try { return (await runFetch(url, { signal: AbortSignal.timeout(timeoutMs) })).ok; } catch { return false; } }, catch: () => false }); const LOCAL_PROBE_DEADLINE_MS = 9e4; const LOCAL_PROBE_INTERVAL_MS = 200; const cleanExitSignals = { SIGTERM: true, SIGINT: true }; const REMOTE_PROBE_TIMEOUT_MS = 5e3; const REMOTE_PROBE_DEADLINE_MS = 6e4; const REMOTE_PROBE_BACKOFF_INITIAL_MS = 1e3; const REMOTE_PROBE_BACKOFF_MAX_MS = 15e3; const detectStatus = (line, descriptor) => { const cleanLine = stripAnsi(line); const errorPatterns = descriptor.errorPatterns ?? []; const readyPatterns = descriptor.readyPatterns ?? []; for (const pattern of readyPatterns) if (pattern.test(cleanLine)) return { status: "ready", isError: false }; for (const pattern of errorPatterns) if (pattern.test(cleanLine)) return { status: "error", isError: true }; return null; }; const patchConsole = (name, callbacks) => { const originalLog = console.log; const originalError = console.error; const originalWarn = console.warn; const originalInfo = console.info; const formatArgs = (args, isError = false) => { return args.map((arg) => { if (arg instanceof Error) { const parts = [`${arg.name}: ${arg.message}`]; if (arg.cause instanceof Error) parts.push(`(cause: ${arg.cause.name}: ${arg.cause.message})`); else if (arg.cause) { const cause = arg.cause; parts.push(`(cause: ${cause instanceof Error ? cause.message : typeof cause === "string" ? cause : JSON.stringify(cause) ?? "[Unserializable cause]"})`); } if (isError && arg.stack) parts.push(arg.stack); return parts.join("\n"); } if (arg === null) return "null"; if (typeof arg === "string") return arg; if (typeof arg === "number" || typeof arg === "bigint" || typeof arg === "boolean") return `${arg}`; if (typeof arg === "symbol") return arg.description ?? ""; if (typeof arg === "undefined") return "undefined"; return JSON.stringify(arg, null, 2) ?? "[Unserializable value]"; }).join(" "); }; console.log = (...args) => { callbacks.onLog(name, formatArgs(args), false); }; console.error = (...args) => { callbacks.onLog(name, formatArgs(args, true), true); }; console.warn = (...args) => { callbacks.onLog(name, formatArgs(args), false); }; console.info = (...args) => { callbacks.onLog(name, formatArgs(args), false); }; return () => { console.log = originalLog; console.error = originalError; console.warn = originalWarn; console.info = originalInfo; }; }; var HostRemoteUrlMissing = class extends Data.TaggedError("HostRemoteUrlMissing") { get message() { return "remoteUrl not provided on host descriptor"; } }; var HostModuleInvalid = class extends Data.TaggedError("HostModuleInvalid") { get message() { return "Host module does not export runServer function"; } }; var LocalPathMissing = class extends Data.TaggedError("LocalPathMissing") { get message() { return `No localPath for local service: ${this.key}`; } }; const spawnRemoteHost = (descriptor, callbacks) => Effect.gen(function* () { const runtimeConfig = yield* DevRuntimeConfig; const remoteUrl = descriptor.remoteUrl; if (!remoteUrl) return yield* new HostRemoteUrlMissing(); callbacks.onStatus(descriptor.key, "starting"); callbacks.onLog(descriptor.key, `Remote: ${remoteUrl}`); const restoreConsole = patchConsole(descriptor.key, callbacks); callbacks.onLog(descriptor.key, "Loading Module Federation runtime..."); const mfRuntime = yield* Effect.tryPromise({ try: () => import("@module-federation/enhanced/runtime"), catch: (cause) => new OrchestratorError({ operation: "load module federation runtime", detail: cause instanceof Error ? cause.message : String(cause), cause }) }); const mfCore = yield* Effect.tryPromise({ try: () => import("@module-federation/runtime-core"), catch: (cause) => new OrchestratorError({ operation: "load module federation core", detail: cause instanceof Error ? cause.message : String(cause), cause }) }); let mf = mfRuntime.getInstance(); if (!mf) { mf = mfRuntime.createInstance({ name: "cli-host", remotes: [] }); mfCore.setGlobalFederationInstance(mf); } patchManifestFetchForSsrPublicPath(mf); const baseUrl = remoteUrl.replace(/\/remoteEntry\.js$/, "").replace(/\/mf-manifest\.json$/, "").replace(/\/$/, ""); const remoteEntryUrl = `${baseUrl}/remoteEntry.js`; const manifestUrl = `${baseUrl}/mf-manifest.json`; const entryUrl = yield* Effect.tryPromise({ try: async () => { try { let res; try { res = await runFetch(manifestUrl, { signal: AbortSignal.timeout(1e4) }); } catch { throw new Error("manifest fetch failed"); } if (!res.ok) return remoteEntryUrl; const json = await res.json(); if (json && typeof json === "object" && "metaData" in json && "exposes" in json && "shared" in json) return manifestUrl; } catch (e) { warnOutsideEffect(`[Orchestrator] Failed to fetch or parse manifest from ${manifestUrl}, falling back to remoteEntryUrl: ${String(e)}`); } return remoteEntryUrl; }, catch: () => remoteEntryUrl }); mf.registerRemotes([{ name: "host", entry: entryUrl }]); callbacks.onLog(descriptor.key, `Loading host from ${entryUrl}...`); const hostModule = yield* Effect.tryPromise({ try: () => mf.loadRemote("host/Server"), catch: (cause) => new OrchestratorError({ operation: "load host module", detail: cause instanceof Error ? cause.message : String(cause), cause }) }); if (!hostModule?.runServer) return yield* new HostModuleInvalid(); callbacks.onLog(descriptor.key, "Starting server..."); const hostPort = runtimeConfig.host?.port; const hostEnv = hostPort ? { PORT: String(hostPort) } : void 0; const serverHandle = hostModule.runServer({ config: runtimeConfig, port: hostPort, env: hostEnv }); yield* Effect.tryPromise({ try: () => serverHandle.ready, catch: (cause) => new OrchestratorError({ operation: "start server", detail: cause instanceof Error ? cause.message : String(cause), cause }) }); callbacks.onStatus(descriptor.key, "ready"); return { name: descriptor.key, pid: void 0, kill: Effect.gen(function* () { callbacks.onLog(descriptor.key, "Shutting down remote host..."); restoreConsole(); yield* Effect.tryPromise({ try: () => serverHandle.shutdown(), catch: () => {} }).pipe(Effect.ignore); }), waitForReady: Effect.void, waitForExit: Effect.never }; }); /** * Spawn env precedence, three tiers: values explicitly exported by the * caller (shell / CI / regression harness — captured by the bootstrap * program into the `ShellEnv` service before any `.env` loading) outrank the * generated infra env, which outranks `.env`-file values inherited through * `processEnv`. The service's resolved port is always authoritative. */ function composeSpawnEnv(processEnv, generatedEnv, port, shellEnv = {}) { return { ...processEnv, ...mergeGeneratedOverFileEnv(generatedEnv, processEnv, shellEnv), FORCE_COLOR: "1", ...port > 0 ? { PORT: String(port) } : {} }; } /** * Overlay the generated infra env (ports drift, so stale `.env` values must * lose) while keeping every explicitly exported key from `shellEnv` intact. * Keys absent from both are untouched. * * Exception (ADR 0012 / plan 038): the origin keys BASE_URL and CORS_ORIGIN * are always generated-owned — a wrapper that sourced a stale `.env` must not * win over the resolved host port, in the child env or on disk. */ const GENERATED_OWNED_KEYS = /* @__PURE__ */ new Set(["BASE_URL", "CORS_ORIGIN"]); function mergeGeneratedOverFileEnv(generatedEnv, processEnv, shellEnv = {}) { const result = { ...processEnv }; for (const [key, value] of Object.entries(generatedEnv)) if (!GENERATED_OWNED_KEYS.has(key) && key in shellEnv) result[key] = shellEnv[key]; else result[key] = value; return result; } const spawnDevProcess = (descriptor, callbacks) => Effect.gen(function* () { const runtimeConfig = yield* DevRuntimeConfig; if (!descriptor.localPath) return yield* new LocalPathMissing({ key: descriptor.key }); const fullCwd = descriptor.localPath; const command = descriptor.command ?? "pnpm"; const args = descriptor.args ?? ["run", "dev"]; const port = descriptor.port ?? descriptor.defaultPort; const name = descriptor.key; const readyDeferred = yield* Deferred.make(); const statusRef = yield* Ref.make("starting"); callbacks.onStatus(name, "starting"); const generatedEnv = yield* DevGeneratedEnv; const shellTier = yield* ShellEnv; const envVars = composeSpawnEnv(process.env, { ...generatedEnv, ...descriptor.env }, port, shellTier); envVars.BOS_RUNTIME_CONFIG = yield* encodeRuntimeConfig(runtimeConfig); const cmd = spawn(command, args, { cwd: fullCwd, env: envVars, stdio: [ "ignore", "pipe", "pipe" ], detached: true }); let lastExit = null; const exitCode = Effect.callback((resume) => { const onExit = (code, signal) => { lastExit = { code, signal }; resume(Effect.succeed(code ?? (signal ? 1 : 0))); }; const onError = (err) => { callbacks.onLog(name, `Spawn failed: ${err.message}`, true); callbacks.onStatus(name, "error", `spawn failed: ${err.message}`); resume(Effect.fail(new OrchestratorError({ operation: "spawn process", detail: err.message, cause: err }))); }; cmd.once("exit", onExit); cmd.once("error", onError); return Effect.sync(() => { cmd.off("exit", onExit); cmd.off("error", onError); }); }); const describeExit = (exitCodeValue) => { if (lastExit?.code === null && lastExit.signal) return `signal: ${lastExit.signal}`; return `exit code: ${exitCodeValue}`; }; const isCleanSignalExit = () => lastExit?.code === null && lastExit.signal != null && lastExit.signal in cleanExitSignals; const markReady = Effect.gen(function* () { const currentStatus = yield* Ref.get(statusRef); if (currentStatus === "ready" || currentStatus === "error") return; yield* Ref.set(statusRef, "ready"); callbacks.onStatus(name, "ready"); yield* Deferred.succeed(readyDeferred, void 0).pipe(Effect.ignore); }); const markError = (message) => Effect.gen(function* () { const currentStatus = yield* Ref.get(statusRef); if (currentStatus === "ready" || currentStatus === "error") return; yield* Ref.set(statusRef, "error"); callbacks.onStatus(name, "error"); yield* Deferred.fail(readyDeferred, new OrchestratorError({ operation: "wait for process readiness", detail: message })).pipe(Effect.ignore); }); yield* Effect.forkScoped(Effect.gen(function* () { const readinessPath = descriptor.readinessPath; const url = `http://127.0.0.1:${port}${readinessPath}`; const readinessCheck = Effect.gen(function* () { const status = yield* Ref.get(statusRef); if (status === "ready" || status === "error") return true; if (port <= 0) return false; const ok = yield* probeHttpOk(url); if (ok) yield* markReady; return ok; }); if (yield* Effect.repeat(readinessCheck, { schedule: Schedule.spaced(`${port > 0 ? LOCAL_PROBE_INTERVAL_MS : 500} millis`), until: (done) => done }).pipe(Effect.timeout(`${LOCAL_PROBE_DEADLINE_MS} millis`), Effect.catchTag("TimeoutError", () => Effect.succeed(false)))) return; const status = yield* Ref.get(statusRef); if (status !== "ready" && status !== "error") { callbacks.onLog(name, "Probe deadline exceeded after 90s", true); yield* markError(`Probe deadline exceeded: ${name}`); } })); const pid = cmd.pid ?? void 0; yield* Effect.forkScoped(Effect.gen(function* () { const exitCodeValue = yield* exitCode; const currentStatus = yield* Ref.get(statusRef); if (currentStatus === "ready" || currentStatus === "error") { if (currentStatus === "ready") { const detail = describeExit(exitCodeValue); callbacks.onLog(name, `Process exited after ready (${detail})`, !isCleanSignalExit()); if (!isCleanSignalExit()) callbacks.onStatus(name, "error", `exited after ready (${detail})`); yield* markError(`Process exited after ready: ${name}`); } return; } callbacks.onLog(name, `Process exited before ready (${describeExit(exitCodeValue)})`, !isCleanSignalExit()); yield* markError(`Process exited before ready: ${name}`); })); const handleLine = (line, isStderr) => Effect.gen(function* () { if (!line.trim()) return; const cleanLine = stripAnsi(line); const looksLikeError = isStderr && /^(error|fail|fatal|exception|unhandled|reject)/i.test(cleanLine) && !cleanLine.startsWith("$"); callbacks.onLog(name, line, looksLikeError); const currentStatus = yield* Ref.get(statusRef); if (currentStatus === "ready") return; const detected = detectStatus(line, descriptor); if (detected) { if (detected.status === "ready") yield* markReady; else if (currentStatus !== "error") yield* markError(`Process failed: ${name}`); } }); const stdoutStream = cmd.stdout ? Readable.toWeb(cmd.stdout) : null; const stderrStream = cmd.stderr ? Readable.toWeb(cmd.stderr) : null; if (stdoutStream) yield* Effect.forkScoped(Stream.runForEach((line) => handleLine(line, false))(Stream.splitLines(Stream.decodeText(Stream.fromReadableStream({ evaluate: () => stdoutStream, onError: (cause) => new OrchestratorError({ operation: "read process stdout", detail: String(cause), cause }) }))))); if (stderrStream) yield* Effect.forkScoped(Stream.runForEach((line) => handleLine(line, true))(Stream.splitLines(Stream.decodeText(Stream.fromReadableStream({ evaluate: () => stderrStream, onError: (cause) => new OrchestratorError({ operation: "read process stderr", detail: String(cause), cause }) }))))); return { name, pid, kill: Effect.gen(function* () { const groupPid = pid; const groupSignal = (signal) => Effect.try(() => { if (groupPid !== void 0) process.kill(-groupPid, signal); }).pipe(Effect.ignore); yield* groupSignal("SIGTERM"); const exited = yield* exitCode.pipe(Effect.timeout("3 seconds"), Effect.option); if (Option.isNone(exited)) { yield* groupSignal("SIGKILL"); yield* Effect.sleep("250 millis"); } }).pipe(Effect.ignore), waitForReady: Deferred.await(readyDeferred), waitForExit: exitCode }; }); const spawnRemoteProbe = (pkg, descriptor, callbacks) => Effect.gen(function* () { callbacks.onStatus(pkg, "starting"); const readyDeferred = yield* Deferred.make(); const statusRef = yield* Ref.make("starting"); const markReady = Effect.gen(function* () { const currentStatus = yield* Ref.get(statusRef); if (currentStatus === "ready" || currentStatus === "error") return; yield* Ref.set(statusRef, "ready"); yield* Deferred.succeed(readyDeferred, void 0).pipe(Effect.ignore); callbacks.onStatus(pkg, "ready", "loaded"); }); const markError = Effect.gen(function* () { const currentStatus = yield* Ref.get(statusRef); if (currentStatus === "ready" || currentStatus === "error") return; yield* Ref.set(statusRef, "error"); yield* Deferred.fail(readyDeferred, new OrchestratorError({ operation: "probe remote process", detail: `Remote ${pkg} unreachable` })).pipe(Effect.ignore); callbacks.onStatus(pkg, "error", "unreachable"); }); const baseUrl = descriptor.url.replace(/\/$/, ""); const manifestUrl = `${baseUrl}/mf-manifest.json`; const entryUrl = `${baseUrl}${descriptor.readinessPath}`; const probeUrl = descriptor.readinessPath === "/health" ? `${baseUrl}/health` : manifestUrl; yield* Effect.forkScoped(Effect.gen(function* () { const readinessCheck = Effect.gen(function* () { const status = yield* Ref.get(statusRef); if (status === "ready" || status === "error") return true; if (yield* probeHttpOk(probeUrl, REMOTE_PROBE_TIMEOUT_MS)) { yield* markReady; return true; } if (yield* probeHttpOk(entryUrl, REMOTE_PROBE_TIMEOUT_MS)) { yield* markReady; return true; } return false; }); if (yield* Effect.repeat(readinessCheck, { schedule: Schedule.min([Schedule.exponential(`${REMOTE_PROBE_BACKOFF_INITIAL_MS} millis`, 1.5), Schedule.spaced(`${REMOTE_PROBE_BACKOFF_MAX_MS} millis`)]), until: (done) => done }).pipe(Effect.timeout(`${REMOTE_PROBE_DEADLINE_MS} millis`), Effect.catchTag("TimeoutError", () => Effect.succeed(false)))) return; if ((yield* Ref.get(statusRef)) !== "ready") yield* markError; })); return { name: pkg, pid: void 0, kill: Effect.gen(function* () { yield* Ref.set(statusRef, "error"); yield* Deferred.fail(readyDeferred, new OrchestratorError({ operation: "stop remote process", detail: "Killed" })).pipe(Effect.ignore); }), waitForReady: Deferred.await(readyDeferred), waitForExit: Effect.never }; }); const makeDevProcess = (pkg, callbacks, portOverride) => Effect.gen(function* () { const descriptor = (yield* ServiceDescriptorMap).get(pkg); if (!descriptor) { callbacks.onStatus(pkg, "ready", "Remote"); return { name: pkg, pid: void 0, kill: Effect.void, waitForReady: Effect.void, waitForExit: Effect.never }; } if (pkg === "host" && descriptor.source === "remote") return yield* spawnRemoteHost(descriptor, callbacks); if (descriptor.source === "remote" || !descriptor.localPath) return yield* spawnRemoteProbe(pkg, descriptor, callbacks); const resolvedDescriptor = portOverride ? { ...descriptor, port: portOverride } : descriptor; return yield* spawnDevProcess(resolvedDescriptor, callbacks); }); function getProcessStates(packages, services, portOverride) { return packages.map((pkg) => { const descriptor = services.get(pkg); return { name: pkg, status: "pending", port: portOverride && pkg === "host" ? portOverride : descriptor?.port ?? descriptor?.defaultPort ?? 0, source: descriptor?.source, uiPort: descriptor?.uiPort }; }); } //#endregion export { HostModuleInvalid, HostRemoteUrlMissing, LocalPathMissing, OrchestratorError, composeSpawnEnv, detectStatus, encodeRuntimeConfig, getProcessStates, makeDevProcess, mergeGeneratedOverFileEnv }; //# sourceMappingURL=orchestrator.mjs.map