UNPKG

everything-dev

Version:

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

525 lines (523 loc) • 20.9 kB
const require_service_descriptor = require('./service-descriptor.cjs'); const require_dev_log_pipeline = require('./dev-log-pipeline.cjs'); const require_mf = require('./mf.cjs'); const require_project_env = require('./env/project-env.cjs'); let effect = require("effect"); let node_child_process = require("node:child_process"); let node_stream = require("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 effect.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) => effect.Schema.encodeUnknownEffect(effect.Schema.fromJsonString(effect.Schema.Json))(omitUndefined(runtimeConfig)).pipe(effect.Effect.mapError((cause) => new OrchestratorError({ operation: "serialize runtime config", detail: cause.message, cause }))); const probeHttpOk = (url, timeoutMs = 400) => effect.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 = require_dev_log_pipeline.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 effect.Data.TaggedError("HostRemoteUrlMissing") { get message() { return "remoteUrl not provided on host descriptor"; } }; var HostModuleInvalid = class extends effect.Data.TaggedError("HostModuleInvalid") { get message() { return "Host module does not export runServer function"; } }; var LocalPathMissing = class extends effect.Data.TaggedError("LocalPathMissing") { get message() { return `No localPath for local service: ${this.key}`; } }; const spawnRemoteHost = (descriptor, callbacks) => effect.Effect.gen(function* () { const runtimeConfig = yield* require_service_descriptor.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.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.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); } require_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.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.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.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.Effect.gen(function* () { callbacks.onLog(descriptor.key, "Shutting down remote host..."); restoreConsole(); yield* effect.Effect.tryPromise({ try: () => serverHandle.shutdown(), catch: () => {} }).pipe(effect.Effect.ignore); }), waitForReady: effect.Effect.void, waitForExit: effect.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.Effect.gen(function* () { const runtimeConfig = yield* require_service_descriptor.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* effect.Deferred.make(); const statusRef = yield* effect.Ref.make("starting"); callbacks.onStatus(name, "starting"); const generatedEnv = yield* require_service_descriptor.DevGeneratedEnv; const shellTier = yield* require_project_env.ShellEnv; const envVars = composeSpawnEnv(process.env, { ...generatedEnv, ...descriptor.env }, port, shellTier); envVars.BOS_RUNTIME_CONFIG = yield* encodeRuntimeConfig(runtimeConfig); const cmd = (0, node_child_process.spawn)(command, args, { cwd: fullCwd, env: envVars, stdio: [ "ignore", "pipe", "pipe" ], detached: true }); let lastExit = null; const exitCode = effect.Effect.callback((resume) => { const onExit = (code, signal) => { lastExit = { code, signal }; resume(effect.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.Effect.fail(new OrchestratorError({ operation: "spawn process", detail: err.message, cause: err }))); }; cmd.once("exit", onExit); cmd.once("error", onError); return effect.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.Effect.gen(function* () { const currentStatus = yield* effect.Ref.get(statusRef); if (currentStatus === "ready" || currentStatus === "error") return; yield* effect.Ref.set(statusRef, "ready"); callbacks.onStatus(name, "ready"); yield* effect.Deferred.succeed(readyDeferred, void 0).pipe(effect.Effect.ignore); }); const markError = (message) => effect.Effect.gen(function* () { const currentStatus = yield* effect.Ref.get(statusRef); if (currentStatus === "ready" || currentStatus === "error") return; yield* effect.Ref.set(statusRef, "error"); callbacks.onStatus(name, "error"); yield* effect.Deferred.fail(readyDeferred, new OrchestratorError({ operation: "wait for process readiness", detail: message })).pipe(effect.Effect.ignore); }); yield* effect.Effect.forkScoped(effect.Effect.gen(function* () { const readinessPath = descriptor.readinessPath; const url = `http://127.0.0.1:${port}${readinessPath}`; const readinessCheck = effect.Effect.gen(function* () { const status = yield* effect.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.Effect.repeat(readinessCheck, { schedule: effect.Schedule.spaced(`${port > 0 ? LOCAL_PROBE_INTERVAL_MS : 500} millis`), until: (done) => done }).pipe(effect.Effect.timeout(`${LOCAL_PROBE_DEADLINE_MS} millis`), effect.Effect.catchTag("TimeoutError", () => effect.Effect.succeed(false)))) return; const status = yield* effect.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.Effect.forkScoped(effect.Effect.gen(function* () { const exitCodeValue = yield* exitCode; const currentStatus = yield* effect.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.Effect.gen(function* () { if (!line.trim()) return; const cleanLine = require_dev_log_pipeline.stripAnsi(line); const looksLikeError = isStderr && /^(error|fail|fatal|exception|unhandled|reject)/i.test(cleanLine) && !cleanLine.startsWith("$"); callbacks.onLog(name, line, looksLikeError); const currentStatus = yield* effect.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 ? node_stream.Readable.toWeb(cmd.stdout) : null; const stderrStream = cmd.stderr ? node_stream.Readable.toWeb(cmd.stderr) : null; if (stdoutStream) yield* effect.Effect.forkScoped(effect.Stream.runForEach((line) => handleLine(line, false))(effect.Stream.splitLines(effect.Stream.decodeText(effect.Stream.fromReadableStream({ evaluate: () => stdoutStream, onError: (cause) => new OrchestratorError({ operation: "read process stdout", detail: String(cause), cause }) }))))); if (stderrStream) yield* effect.Effect.forkScoped(effect.Stream.runForEach((line) => handleLine(line, true))(effect.Stream.splitLines(effect.Stream.decodeText(effect.Stream.fromReadableStream({ evaluate: () => stderrStream, onError: (cause) => new OrchestratorError({ operation: "read process stderr", detail: String(cause), cause }) }))))); return { name, pid, kill: effect.Effect.gen(function* () { const groupPid = pid; const groupSignal = (signal) => effect.Effect.try(() => { if (groupPid !== void 0) process.kill(-groupPid, signal); }).pipe(effect.Effect.ignore); yield* groupSignal("SIGTERM"); const exited = yield* exitCode.pipe(effect.Effect.timeout("3 seconds"), effect.Effect.option); if (effect.Option.isNone(exited)) { yield* groupSignal("SIGKILL"); yield* effect.Effect.sleep("250 millis"); } }).pipe(effect.Effect.ignore), waitForReady: effect.Deferred.await(readyDeferred), waitForExit: exitCode }; }); const spawnRemoteProbe = (pkg, descriptor, callbacks) => effect.Effect.gen(function* () { callbacks.onStatus(pkg, "starting"); const readyDeferred = yield* effect.Deferred.make(); const statusRef = yield* effect.Ref.make("starting"); const markReady = effect.Effect.gen(function* () { const currentStatus = yield* effect.Ref.get(statusRef); if (currentStatus === "ready" || currentStatus === "error") return; yield* effect.Ref.set(statusRef, "ready"); yield* effect.Deferred.succeed(readyDeferred, void 0).pipe(effect.Effect.ignore); callbacks.onStatus(pkg, "ready", "loaded"); }); const markError = effect.Effect.gen(function* () { const currentStatus = yield* effect.Ref.get(statusRef); if (currentStatus === "ready" || currentStatus === "error") return; yield* effect.Ref.set(statusRef, "error"); yield* effect.Deferred.fail(readyDeferred, new OrchestratorError({ operation: "probe remote process", detail: `Remote ${pkg} unreachable` })).pipe(effect.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.Effect.forkScoped(effect.Effect.gen(function* () { const readinessCheck = effect.Effect.gen(function* () { const status = yield* effect.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.Effect.repeat(readinessCheck, { schedule: effect.Schedule.min([effect.Schedule.exponential(`${REMOTE_PROBE_BACKOFF_INITIAL_MS} millis`, 1.5), effect.Schedule.spaced(`${REMOTE_PROBE_BACKOFF_MAX_MS} millis`)]), until: (done) => done }).pipe(effect.Effect.timeout(`${REMOTE_PROBE_DEADLINE_MS} millis`), effect.Effect.catchTag("TimeoutError", () => effect.Effect.succeed(false)))) return; if ((yield* effect.Ref.get(statusRef)) !== "ready") yield* markError; })); return { name: pkg, pid: void 0, kill: effect.Effect.gen(function* () { yield* effect.Ref.set(statusRef, "error"); yield* effect.Deferred.fail(readyDeferred, new OrchestratorError({ operation: "stop remote process", detail: "Killed" })).pipe(effect.Effect.ignore); }), waitForReady: effect.Deferred.await(readyDeferred), waitForExit: effect.Effect.never }; }); const makeDevProcess = (pkg, callbacks, portOverride) => effect.Effect.gen(function* () { const descriptor = (yield* require_service_descriptor.ServiceDescriptorMap).get(pkg); if (!descriptor) { callbacks.onStatus(pkg, "ready", "Remote"); return { name: pkg, pid: void 0, kill: effect.Effect.void, waitForReady: effect.Effect.void, waitForExit: effect.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 exports.HostModuleInvalid = HostModuleInvalid; exports.HostRemoteUrlMissing = HostRemoteUrlMissing; exports.LocalPathMissing = LocalPathMissing; exports.OrchestratorError = OrchestratorError; exports.composeSpawnEnv = composeSpawnEnv; exports.detectStatus = detectStatus; exports.encodeRuntimeConfig = encodeRuntimeConfig; exports.getProcessStates = getProcessStates; exports.makeDevProcess = makeDevProcess; exports.mergeGeneratedOverFileEnv = mergeGeneratedOverFileEnv; //# sourceMappingURL=orchestrator.cjs.map