Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 68 additions & 32 deletions host/src/services/plugins.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createInstance, getInstance } from "@module-federation/enhanced/runtime";
import { setGlobalFederationInstance } from "@module-federation/runtime-core";
import { Config, Context, Data, Effect, Layer, Option, Redacted } from "effect";
import { Cause, Config, Context, Data, Effect, Layer, Option, Redacted } from "effect";
import { createPluginRuntime } from "every-plugin";
import type { PluginLoadFailureInfo, PluginWarningInfo } from "every-plugin/errors";
import {
Expand Down Expand Up @@ -29,7 +29,7 @@ import { originMatches } from "../utils/origin-match";
import { ConfigService, readCorsOrigins } from "./config";
import { PluginError } from "./errors";

class PluginBootstrapError extends Data.TaggedError("PluginBootstrapError")<{
export class PluginBootstrapError extends Data.TaggedError("PluginBootstrapError")<{
pluginKey: string;
pluginUrl?: string;
stage: "load" | "init" | "db-preflight" | "db-migration";
Expand Down Expand Up @@ -420,15 +420,38 @@ const logBootstrapError = Effect.fn("logBootstrapError")(function* (err: PluginB
}
});

/**
* Normalize any per-plugin failure — a typed `PluginBootstrapError`, a foreign
* typed failure (config reads, auth origin derivation), or a thrown defect —
* into the `PluginBootstrapError` the load loop records. A typed
* `PluginBootstrapError` passes through unchanged so stage attribution and
* failure classification are preserved.
*/
export function pluginFailureCause(
entry: { key: string; config: { url?: string } },
cause: Cause.Cause<unknown>,
): PluginBootstrapError {
const fail = cause.reasons.find(Cause.isFailReason);
if (fail?.error instanceof PluginBootstrapError) return fail.error;
return new PluginBootstrapError({
pluginKey: entry.key,
pluginUrl: entry.config?.url ?? "unknown",
stage: "init",
execution: "local-host-process",
cause: Cause.squash(cause),
});
}

const loadPluginEntryEffect = Effect.fn("loadPluginEntryEffect")(function* (params: {
runtime: any;
entry: RuntimePluginEntry;
integrityRegistry: IntegrityRegistry;
env: BosEnv;
config: RuntimeConfig;
corsOrigins: string[];
pluginsClient?: Record<string, unknown>;
baseVariables?: Record<string, unknown>;
}) {
const { runtime, entry, integrityRegistry, env, pluginsClient, baseVariables } = params;
const { runtime, entry, integrityRegistry, env, config, corsOrigins, pluginsClient } = params;
if (entry.config.integrity) {
integrityRegistry.registerEntry(entry.config.url, entry.config.integrity);
}
Expand All @@ -443,8 +466,22 @@ const loadPluginEntryEffect = Effect.fn("loadPluginEntryEffect")(function* (para
: "API_DATABASE_URL"
: pluginDbSecretKey;

const variables: Record<string, unknown> = { ...baseVariables, ...entry.config.variables };
const secrets = yield* resolveSecretsWithDatabaseFallback(entry.config.secrets ?? []);
const variables: Record<string, unknown> = {
...(entry.key === "auth" ? yield* buildAuthBaseVariables(config, corsOrigins) : undefined),
...entry.config.variables,
};
const secrets = yield* resolveSecretsWithDatabaseFallback(entry.config.secrets ?? []).pipe(
Effect.mapError(
(cause) =>
new PluginBootstrapError({
pluginKey: entry.key,
pluginUrl: entry.config.url,
stage: "init",
execution: "local-host-process",
cause,
}),
),
);
const dbSecret = secretKey
? secrets[secretKey]
? Redacted.make(secrets[secretKey])
Expand Down Expand Up @@ -631,6 +668,26 @@ export const initializePlugins = Effect.fn("initializePlugins")(
let authClient: ((ctx?: unknown) => unknown) | null = null;
let baseApi: HostPluginEntry | null = null;

const recordPluginFailure = Effect.fn("recordPluginFailure")(function* (
err: PluginBootstrapError,
) {
yield* logBootstrapError(err);
errors.push(err.message);
failures.push({
pluginKey: err.pluginKey,
pluginUrl: err.pluginUrl,
operation: err.operation,
kind: err.classification.kind,
retryable: err.classification.retryable,
message: err.message,
suggestion: err.classification.suggestion,
dbSecret: err.dbSecret,
dbUrlMasked: err.dbUrlMasked,
});
pluginsClient[err.pluginKey] = failedPluginsClientEntry(err.message);
return null;
});

for (const key of loadableEntries) {
const entry = entryMap.get(key)!;
const node = dag.nodes.get(key)!;
Expand Down Expand Up @@ -661,41 +718,20 @@ export const initializePlugins = Effect.fn("initializePlugins")(
}
}

let baseVariables: Record<string, unknown> | undefined;
if (node.kind === "auth") {
baseVariables = yield* buildAuthBaseVariables(config, corsOrigins);
}

yield* Effect.logInfo(`[Plugins][${key}] Loading (${entry.config.name})`);

const result = yield* loadPluginEntryEffect({
runtime,
entry,
integrityRegistry,
env: config.env,
config,
corsOrigins,
pluginsClient: Object.keys(nodePluginsClient).length > 0 ? nodePluginsClient : undefined,
baseVariables,
}).pipe(
Effect.catchTag("PluginBootstrapError", (err: PluginBootstrapError) =>
Effect.gen(function* () {
yield* logBootstrapError(err);
errors.push(err.message);
failures.push({
pluginKey: err.pluginKey,
pluginUrl: err.pluginUrl,
operation: err.operation,
kind: err.classification.kind,
retryable: err.classification.retryable,
message: err.message,
suggestion: err.classification.suggestion,
dbSecret: err.dbSecret,
dbUrlMasked: err.dbUrlMasked,
});
if (node.kind === "plugin") {
pluginsClient[key] = failedPluginsClientEntry(err.message);
}
return null;
}),
Effect.catchCauseIf(
(cause) => !Cause.hasInterruptsOnly(cause),
(cause) => recordPluginFailure(pluginFailureCause(entry, cause)),
),
);

Expand Down
123 changes: 123 additions & 0 deletions host/tests/integration/plugin-failure-isolation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { ConfigProvider, Effect } from "effect";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RuntimeConfig } from "../../src/services/config";

const usePluginMock = vi.fn();

vi.mock("@module-federation/enhanced/runtime", () => ({
createInstance: () => null,
}));
vi.mock("every-plugin", () => ({
createPluginRuntime: () => ({ __mfInstance: undefined, usePlugin: usePluginMock }),
}));
vi.mock("every-plugin/remote-entry", async (importOriginal) => {
const original = await importOriginal<typeof import("every-plugin/remote-entry")>();
return {
...original,
loadRemoteWithRetry: (options: Parameters<typeof original.loadRemoteWithRetry>[0]) =>
original.loadRemoteWithRetry({
// Tests stub `load`; the cold-compile poll would hit the network.
...options,
readinessTimeoutMs: 0,
}),
};
});
vi.mock("everything-dev/integrity", () => ({
IntegrityRegistry: class {
registerEntry() {}
},
verifyConfigAgainstChain: async () => ({ verified: true, mismatches: [] }),
}));

const { initializePlugins } = await import("../../src/services/plugins");
const { ConfigService } = await import("../../src/services/config");

function makeLoaded(key: string) {
return {
createClient: (): unknown => ({}),
router: { key },
metadata: { remoteUrl: `https://cdn.example.com/${key}`, version: "1.0.0" },
name: key,
};
}

function slot(name: string) {
return {
name,
url: `https://cdn.example.com/${name}`,
entry: `https://cdn.example.com/${name}/mf-manifest.json`,
entryUrl: `https://cdn.example.com/${name}/remoteEntry.js`,
source: "remote" as const,
};
}

function makeConfig(overrides: Partial<RuntimeConfig>, pluginNames: string[]): RuntimeConfig {
return {
env: "production",
account: "test.near",
networkId: "mainnet",
ui: slot("ui"),
api: { name: "api" },
plugins: Object.fromEntries(pluginNames.map((name) => [name, slot(name)])),
...overrides,
} as unknown as RuntimeConfig;
}

function run(currentConfig: RuntimeConfig) {
return Effect.runPromise(
Effect.scoped(
initializePlugins().pipe(
Effect.provideService(
// Hermetic env — vitest's ambient "/" BASE_URL must not leak into the
// origin derivation under test.
ConfigProvider.ConfigProvider,
ConfigProvider.fromEnv({}),
),
Effect.provideService(ConfigService, currentConfig),
),
),
);
}

beforeEach(() => {
usePluginMock.mockReset();
});

describe("plugin failure isolation", () => {
it("still loads a healthy plugin when another plugin's bundle fails to load", async () => {
usePluginMock.mockImplementation((runtimeId: string) =>
runtimeId === "dead"
? Promise.reject(new Error("Module not found: dead/exploded-remote"))
: Promise.resolve(makeLoaded(runtimeId)),
);

const result = await run(makeConfig({}, ["dead", "votes"]));

expect(result.status.loadedPlugins).toContain("votes");
expect(result.status.loadedPlugins).not.toContain("dead");
expect(result.plugins.votes).toBeDefined();
expect(result.status.failures).toHaveLength(1);
expect(result.status.failures[0]?.pluginKey).toBe("dead");
});

it("isolates a plugin whose bootstrap dies fatally (production origin misconfig)", async () => {
usePluginMock.mockImplementation((runtimeId: string) => Promise.resolve(makeLoaded(runtimeId)));

const result = await run(
makeConfig(
{
host: undefined,
domain: undefined,
auth: { ...slot("auth"), name: "auth" },
} as Partial<RuntimeConfig>,
["votes"],
),
);

expect(result.auth).toBeNull();
expect(result.status.loadedPlugins).toContain("votes");
expect(result.status.failures).toHaveLength(1);
expect(result.status.failures[0]?.pluginKey).toBe("auth");
expect(result.status.failures[0]?.message).toContain("No reachable origin");
});
});
43 changes: 43 additions & 0 deletions host/tests/unit/plugins-failure-cause.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Cause } from "effect";
import { describe, expect, it } from "vitest";
import { PluginBootstrapError, pluginFailureCause } from "../../src/services/plugins";

const entry = { key: "votes", config: { url: "http://cdn.example.com/votes" } };

describe("pluginFailureCause", () => {
it("passes a typed PluginBootstrapError through unchanged", () => {
const typed = new PluginBootstrapError({
pluginKey: "votes",
pluginUrl: entry.config.url,
stage: "db-migration",
dbSecret: "VOTES_DATABASE_URL",
dbUrlMasked: "postgres://…",
execution: "local-host-process",
cause: new Error("migration boom"),
});

expect(pluginFailureCause(entry, Cause.fail(typed))).toBe(typed);
});

it("synthesizes a stage-init error naming the plugin when a defect escapes", () => {
const defect = new TypeError("undefined is not a function");

const err = pluginFailureCause(entry, Cause.die(defect));

expect(err._tag).toBe("PluginBootstrapError");
expect(err.pluginKey).toBe("votes");
expect(err.pluginUrl).toBe(entry.config.url);
expect(err.stage).toBe("init");
expect(err.cause).toBe(defect);
});

it("synthesizes the same way for foreign typed failures", () => {
const foreign = new Error("config fell over");

const err = pluginFailureCause(entry, Cause.fail(foreign));

expect(err.pluginKey).toBe("votes");
expect(err.stage).toBe("init");
expect(err.cause).toBe(foreign);
});
});
Loading