everything-dev
Version:
A consolidated product package for building Module Federation apps with oRPC APIs.
516 lines (514 loc) • 19.7 kB
JavaScript
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