diff --git a/.changeset/dependency-graph.md b/.changeset/dependency-graph.md new file mode 100644 index 00000000..7e925aa7 --- /dev/null +++ b/.changeset/dependency-graph.md @@ -0,0 +1,13 @@ +--- +"everything-dev": minor +"host": minor +--- + +Move to dependency-graph-based plugin composition with manifest stacking and per-plugin type generation + +- **Dependency DAG**: New `dag.ts` module with `normalizeToNodes()`, `topologicalSort()`, `buildDependencyDAG()`, `mergeManifestNodes()`, `getDependenciesForNode()`, and `getSingletonKey()`. API implicitly depends on all non-ui siblings unless `dependsOn` is explicit. +- **Manifest stacking (one level deep)**: `buildRuntimeConfig` now fetches the API plugin manifest when remote, discovers sub-plugins with secrets/variables, and merges them into the runtime config. Config-declared plugins override manifest-discovered ones. +- **Per-plugin type generation**: `writeGeneratedFiles()` now generates per-plugin `plugins-client.gen.ts` files in `plugins/{key}/src/` when `dependsOn` is declared, and `plugins-types.gen.ts` is filtered by `apiDependsOn`. +- **Host DAG-based loading**: `plugins.ts` loads auth, plugins, and API in DAG order with a singleton cache, using `getDependenciesForNode` to wire per-plugin client contexts. Removed `loadedPluginKeys.unshift("api")` hack. +- **Node-based runtime config**: `RuntimeConfigSchema` gains a `nodes` field populated by `buildRuntimeConfig`, enabling the host to reason about the plugin graph directly. +- **Async `buildRuntimeConfig`**: Now returns `Promise` — all callers updated. diff --git a/.env.example b/.env.example index c508cd6d..c16901a0 100644 --- a/.env.example +++ b/.env.example @@ -3,5 +3,5 @@ API_DATABASE_URL=postgres://everythingdev:everythingdev@localhost:5432/api_db AUTH_DATABASE_URL=postgres://everythingdev:everythingdev@localhost:5433/auth_db -CORS_ORIGIN=http://localhost:4100 +CORS_ORIGIN=http://localhost:3000 diff --git a/host/src/services/plugins.ts b/host/src/services/plugins.ts index cd78d1c8..c47bd9d4 100644 --- a/host/src/services/plugins.ts +++ b/host/src/services/plugins.ts @@ -2,6 +2,7 @@ import { createInstance, getInstance } from "@module-federation/enhanced/runtime import { setGlobalFederationInstance } from "@module-federation/runtime-core"; import { createPluginRuntime } from "every-plugin"; import { Config, ConfigProvider, Context, Data, Effect, Layer, Secret } from "every-plugin/effect"; +import { buildDependencyDAG, getDependenciesForNode, getSingletonKey } from "everything-dev/dag"; import { IntegrityRegistry, verifyConfigAgainstChain } from "everything-dev/integrity"; import { installIntegrityFetchHook } from "everything-dev/mf"; import type { RuntimeConfig, SharedConfig } from "everything-dev/types"; @@ -264,19 +265,6 @@ interface RuntimePluginEntry { config: RuntimeConfig["api"] | RuntimePluginInput; } -function buildRegistryEntries(config: RuntimeConfig): RuntimePluginEntry[] { - const entries: RuntimePluginEntry[] = []; - if (config.api?.url) { - entries.push({ key: "api", runtimeId: config.api.name, config: config.api }); - } - for (const [key, plugin] of Object.entries(config.plugins ?? {})) { - if (plugin.url) { - entries.push({ key, runtimeId: plugin.name, config: plugin }); - } - } - return entries; -} - function collectSecrets(config: { secrets?: string[] }): Record { return secretsFromEnv(config.secrets ?? []); } @@ -408,13 +396,30 @@ export const initializePlugins = Effect.gen(function* () { } satisfies PluginResult; } - const registryEntries = buildRegistryEntries(config); - if (registryEntries.length === 0 && !config.auth) { + const dag = buildDependencyDAG(config); + + const entryMap = new Map(); + if (config.auth?.url) { + entryMap.set("auth", { key: "auth", runtimeId: config.auth.name, config: config.auth }); + } + if (config.api?.url) { + entryMap.set("api", { key: "api", runtimeId: config.api.name, config: config.api }); + } + for (const [key, plugin] of Object.entries(config.plugins ?? {})) { + if (plugin.url) { + entryMap.set(key, { key, runtimeId: plugin.name, config: plugin }); + } + } + + const loadableEntries = [...dag.sorted].filter((k) => entryMap.has(k)); + if (loadableEntries.length === 0 && !config.auth) { yield* Effect.logInfo("[Plugins] No remote plugins configured, using host API only"); return unavailableResult(config.api.name, null, null); } - yield* Effect.logInfo(`[Plugins] Registering ${registryEntries.length} plugin(s)`); + yield* Effect.logInfo( + `[Plugins] Loading ${loadableEntries.length} plugin(s) in DAG order: ${loadableEntries.join(" → ")}`, + ); if (config.env === "production" && config.account) { const bosUrl = `bos://${config.account}/${config.domain ?? "everything.dev"}`; @@ -429,21 +434,16 @@ export const initializePlugins = Effect.gen(function* () { .catch(() => {}); } + const corsOrigins = yield* readCorsOrigins(); + const { runtime, integrityRegistry } = yield* Effect.tryPromise({ try: async () => { - const allEntries: RuntimePluginEntry[] = []; - - if (config.auth?.url) { - allEntries.push({ key: "auth", runtimeId: config.auth.name, config: config.auth }); - } - - allEntries.push(...registryEntries); + const allEntries = [...entryMap.values()]; const integrityRegistry = new IntegrityRegistry(); - const allEntriesWithUrls = allEntries.filter((e) => e.config.url); logger.info( - `[Plugins] Registry entries: ${allEntriesWithUrls.map((e) => `${e.key}=${e.config.url}`).join(", ") || "none"}`, + `[Plugins] Registry entries: ${allEntries.map((e) => `${e.key}=${e.config.url}`).join(", ") || "none"}`, ); await registerAppSharedDeps( @@ -476,100 +476,88 @@ export const initializePlugins = Effect.gen(function* () { const loadedPlugins: Record = {}; const loadedPluginKeys: string[] = []; const pluginsClient: Record = {}; + const singletonCache = new Map(); let authPlugin: HostPluginEntry | null = null; let authClient: ((ctx?: unknown) => unknown) | null = null; let baseApi: HostPluginEntry | null = null; - if (config.auth?.url) { - yield* Effect.logInfo(`[Plugins] Loading auth plugin (${config.auth.name})`); - const authEntry: RuntimePluginEntry = { - key: "auth", - runtimeId: config.auth.name, - config: config.auth, - }; - const authBaseVariables = buildAuthBaseVariables(config, yield* readCorsOrigins()); - const authResult = yield* loadPluginEntryEffect( - runtime, - authEntry, - integrityRegistry, - undefined, - authBaseVariables, - ).pipe( - Effect.catchTag("PluginBootstrapError", (err: PluginBootstrapError) => - Effect.gen(function* () { - yield* logBootstrapError(err); - return null; - }), - ), - ); - if (authResult) { - authPlugin = authResult; - authClient = authResult.createClient; - yield* Effect.logInfo(`[Plugins] Auth plugin loaded: ${authResult.name}`); + for (const key of loadableEntries) { + const entry = entryMap.get(key)!; + const node = dag.nodes.get(key)!; + + const sKey = getSingletonKey(node); + const cached = singletonCache.get(sKey); + if (cached) { + yield* Effect.logInfo(`[Plugins] Reusing singleton ${key} from ${cached.key}`); + loadedPlugins[key] = cached; + loadedPluginKeys.push(key); + pluginsClient[key] = cached.createClient; + + if (node.kind === "auth") { + authPlugin = cached; + authClient = cached.createClient; + } else if (node.kind === "api") { + baseApi = cached; + } + continue; } - } - const pluginEntries = registryEntries.filter((e) => e.key !== "api"); + const deps = getDependenciesForNode(node, dag.nodes); + const nodePluginsClient: Record = {}; + for (const dep of deps) { + if (pluginsClient[dep.key]) { + nodePluginsClient[dep.key] = pluginsClient[dep.key]; + } + } - for (const entry of pluginEntries) { - yield* Effect.logInfo(`[Plugins] Loading plugin (${entry.key})`); - const result = yield* loadPluginEntryEffect(runtime, entry, integrityRegistry).pipe( - Effect.catchTag("PluginBootstrapError", (err: PluginBootstrapError) => - Effect.gen(function* () { - yield* logBootstrapError(err); - errors.push(err.message); - pluginsClient[entry.key] = () => { - throw new Error(err.message); - }; - return null; - }), - ), - ); - if (result) { - loadedPlugins[entry.key] = result; - loadedPluginKeys.push(entry.key); - pluginsClient[entry.key] = result.createClient; - yield* Effect.logInfo(`[Plugins] Plugin loaded: ${entry.key}`); + let baseVariables: Record | undefined; + if (node.kind === "auth") { + baseVariables = buildAuthBaseVariables(config, corsOrigins); } - } - const apiEntry = registryEntries.find((e) => e.key === "api"); + yield* Effect.logInfo(`[Plugins] Loading ${key} (${entry.config.name})`); - if (apiEntry) { - yield* Effect.logInfo(`[Plugins] Loading API plugin (${apiEntry.config.name})`); - const apiPluginsClient: Record = { ...pluginsClient }; - if (authClient) { - apiPluginsClient.auth = authClient; - } - yield* Effect.logInfo(`[Plugins] API auth client available: ${Boolean(authClient)}`); - if (Object.keys(pluginsClient).length > 0) { - yield* Effect.logInfo( - `[Plugins] API plugins available: ${Object.keys(pluginsClient).join(", ")}`, - ); - } - const apiResult = yield* loadPluginEntryEffect( + const result = yield* loadPluginEntryEffect( runtime, - apiEntry, + entry, integrityRegistry, - apiPluginsClient, + Object.keys(nodePluginsClient).length > 0 ? nodePluginsClient : undefined, + baseVariables, ).pipe( Effect.catchTag("PluginBootstrapError", (err: PluginBootstrapError) => Effect.gen(function* () { yield* logBootstrapError(err); errors.push(err.message); + if (node.kind === "plugin") { + pluginsClient[key] = () => { + throw new Error(err.message); + }; + } return null; }), ), ); - if (apiResult) { - baseApi = apiResult; - loadedPlugins.api = apiResult; - loadedPluginKeys.unshift("api"); - yield* Effect.logInfo(`[Plugins] API plugin loaded: ${apiResult.name}`); + + if (result) { + singletonCache.set(sKey, result); + loadedPlugins[key] = result; + loadedPluginKeys.push(key); + pluginsClient[key] = result.createClient; + + if (node.kind === "auth") { + authPlugin = result; + authClient = result.createClient; + yield* Effect.logInfo(`[Plugins] Auth plugin loaded: ${result.name}`); + } else if (node.kind === "api") { + baseApi = result; + yield* Effect.logInfo(`[Plugins] API plugin loaded: ${result.name}`); + } else { + yield* Effect.logInfo(`[Plugins] Plugin loaded: ${key}`); + } } } - const totalPlugins = [authPlugin, ...Object.values(loadedPlugins)].filter(Boolean).length; + const totalPlugins = Object.values(loadedPlugins).filter(Boolean).length; yield* Effect.logInfo(`[Plugins] ${totalPlugins} plugin(s) loaded`); return { diff --git a/host/src/services/tenant-runtime.ts b/host/src/services/tenant-runtime.ts index 77075e9b..5372b263 100644 --- a/host/src/services/tenant-runtime.ts +++ b/host/src/services/tenant-runtime.ts @@ -487,12 +487,17 @@ export async function resolveRequestRuntime( ); } - const tenantRuntimeConfig = buildRuntimeConfig(remoteConfig.config, process.cwd(), "production", { - hostSource: "remote", - uiSource: "remote", - apiSource: "remote", - authSource: "remote", - }); + const tenantRuntimeConfig = await buildRuntimeConfig( + remoteConfig.config, + process.cwd(), + "production", + { + hostSource: "remote", + uiSource: "remote", + apiSource: "remote", + authSource: "remote", + }, + ); const effectiveConfig = buildEffectiveRuntimeConfig( baseConfig, tenantRuntimeConfig, diff --git a/host/tests/integration/tenant-host-nested.test.ts b/host/tests/integration/tenant-host-nested.test.ts index 7e25cff6..4510078a 100644 --- a/host/tests/integration/tenant-host-nested.test.ts +++ b/host/tests/integration/tenant-host-nested.test.ts @@ -225,7 +225,7 @@ describe("tenant host nested integration", () => { ], }); - buildRuntimeConfigMock.mockReturnValue({ + buildRuntimeConfigMock.mockResolvedValue({ ...baseConfig, account: "chicago.alice.linktree.near", title: "Chicago Alice", diff --git a/host/tests/integration/tenant-runtime.test.ts b/host/tests/integration/tenant-runtime.test.ts index e61e0a3a..40bc179b 100644 --- a/host/tests/integration/tenant-runtime.test.ts +++ b/host/tests/integration/tenant-runtime.test.ts @@ -141,7 +141,7 @@ describe("resolveRequestRuntime", () => { extendsChain: ["bos://alice.linktree.near/linktree.com", "bos://linktree.near/linktree.com"], }); - buildRuntimeConfigMock.mockReturnValue({ + buildRuntimeConfigMock.mockResolvedValue({ ...createBaseRuntimeConfig(), account: "alice.linktree.near", ui: { @@ -184,7 +184,7 @@ describe("resolveRequestRuntime", () => { ], }); - buildRuntimeConfigMock.mockReturnValue({ + buildRuntimeConfigMock.mockResolvedValue({ ...createBaseRuntimeConfig(), account: "chicago.alice.linktree.near", ui: { @@ -227,7 +227,7 @@ describe("resolveRequestRuntime", () => { ], }); - buildRuntimeConfigMock.mockReturnValue(createBaseRuntimeConfig()); + buildRuntimeConfigMock.mockResolvedValue(createBaseRuntimeConfig()); await expect( resolveRequestRuntime(createBaseRuntimeConfig(), new Request("https://alice.linktree.com/")), @@ -256,7 +256,7 @@ describe("resolveRequestRuntime", () => { extendsChain: ["bos://alice.linktree.near/linktree.com", "bos://linktree.near/linktree.com"], }); - buildRuntimeConfigMock.mockReturnValue({ + buildRuntimeConfigMock.mockResolvedValue({ ...baseConfig, account: "alice.linktree.near", title: "Alice", @@ -307,7 +307,7 @@ describe("resolveRequestRuntime", () => { extendsChain: ["bos://bob.linktree.near/linktree.com", "bos://linktree.near/linktree.com"], }); - buildRuntimeConfigMock.mockReturnValue({ + buildRuntimeConfigMock.mockResolvedValue({ ...baseConfig, account: "bob.linktree.near", ui: { @@ -353,7 +353,7 @@ describe("resolveRequestRuntime", () => { extendsChain: ["bos://alice.linktree.near/linktree.com", "bos://linktree.near/linktree.com"], }); - buildRuntimeConfigMock.mockReturnValue({ + buildRuntimeConfigMock.mockResolvedValue({ ...baseConfig, account: "alice.linktree.near", ui: { @@ -395,7 +395,7 @@ describe("resolveRequestRuntime", () => { extendsChain: ["bos://alice.linktree.near/linktree.com", "bos://linktree.near/linktree.com"], }); - buildRuntimeConfigMock.mockReturnValue({ + buildRuntimeConfigMock.mockResolvedValue({ ...baseConfig, account: "alice.linktree.near", ui: { @@ -441,7 +441,7 @@ describe("resolveRequestRuntime", () => { extendsChain: ["bos://alice.linktree.near/linktree.com", "bos://linktree.near/linktree.com"], }); - buildRuntimeConfigMock.mockReturnValue({ + buildRuntimeConfigMock.mockResolvedValue({ ...baseConfig, account: "alice.linktree.near", ui: { @@ -501,7 +501,7 @@ describe("resolveRequestRuntime", () => { extendsChain: ["bos://alice.linktree.near/linktree.com", "bos://linktree.near/linktree.com"], }); - buildRuntimeConfigMock.mockReturnValue({ + buildRuntimeConfigMock.mockResolvedValue({ ...baseConfig, account: "alice.linktree.near", ui: { @@ -576,7 +576,7 @@ describe("resolveRequestRuntime", () => { ], }); - buildRuntimeConfigMock.mockReturnValue({ + buildRuntimeConfigMock.mockResolvedValue({ ...baseConfig, account: "alice.linktree.near", ui: { @@ -641,7 +641,7 @@ describe("resolveRequestRuntime", () => { ], }); - buildRuntimeConfigMock.mockReturnValue({ + buildRuntimeConfigMock.mockResolvedValue({ ...baseConfig, account: "alice.linktree.near", ui: { @@ -701,7 +701,7 @@ describe("resolveRequestRuntime", () => { extendsChain: ["bos://bob.linktree.near/linktree.com", "bos://linktree.near/linktree.com"], }); - buildRuntimeConfigMock.mockReturnValue({ + buildRuntimeConfigMock.mockResolvedValue({ ...baseConfig, account: "bob.linktree.near", ui: { diff --git a/packages/everything-dev/package.json b/packages/everything-dev/package.json index 4575b256..775b550f 100644 --- a/packages/everything-dev/package.json +++ b/packages/everything-dev/package.json @@ -36,6 +36,15 @@ "import": "./dist/config.mjs", "require": "./dist/config.cjs" }, + "./dag": { + "development": { + "types": "./src/dag.ts", + "import": "./src/dag.ts" + }, + "types": "./dist/dag.d.mts", + "import": "./dist/dag.mjs", + "require": "./dist/dag.cjs" + }, "./fastkv": { "development": { "types": "./src/fastkv.ts", diff --git a/packages/everything-dev/src/api-contract.ts b/packages/everything-dev/src/api-contract.ts index 7ab8b74b..85f64a3a 100644 --- a/packages/everything-dev/src/api-contract.ts +++ b/packages/everything-dev/src/api-contract.ts @@ -2,7 +2,7 @@ import { createHash } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join, relative } from "node:path"; import { fetchJsonOrNull, fetchResponse } from "./http-client"; -import type { RuntimeConfig, RuntimePluginConfig } from "./types"; +import type { JsonObject, RuntimeConfig, RuntimePluginConfig } from "./types"; export interface ApiPluginManifest { schemaVersion: 1; @@ -28,6 +28,15 @@ export interface ApiPluginManifest { exports: string[]; sha256?: string; }>; + plugins?: Array<{ + key: string; + name: string; + url: string; + dependsOn?: string[]; + secrets?: string[]; + variables?: JsonObject; + }>; + dependsOn?: string[]; } interface ContractSource { @@ -69,7 +78,7 @@ function getApiPluginManifestUrl(apiBaseUrl: string): string { return `${trimTrailingSlash(apiBaseUrl)}/plugin.manifest.json`; } -async function fetchApiPluginManifest(apiBaseUrl: string): Promise { +export async function fetchApiPluginManifest(apiBaseUrl: string): Promise { const url = getApiPluginManifestUrl(apiBaseUrl); const manifest = await fetchJsonOrNull(url, { retries: 0 }); if (!manifest) { @@ -339,12 +348,53 @@ async function resolveContractSource(opts: { }); } -function writeGeneratedFiles(opts: { +function writePluginClientGen(opts: { + configDir: string; + pluginKey: string; + depSources: ContractSource[]; +}) { + const pluginSrcDir = join(opts.configDir, "plugins", opts.pluginKey, "src"); + if (!existsSync(pluginSrcDir)) return; + + const targetPath = join(pluginSrcDir, "lib", "plugins-client.gen.ts"); + const lines: string[] = []; + + for (const source of opts.depSources) { + const importPath = toImportPath(targetPath, source.sourceFilePath); + lines.push(`import type { ContractType as ${source.importName} } from "${importPath}";`); + } + + lines.push('import type { ContractRouterClient, AnyContractRouter } from "@orpc/contract";'); + lines.push( + "type ClientFactory = (context?: Record) => ContractRouterClient;", + ); + lines.push(""); + + if (opts.depSources.length === 0) { + lines.push("export type PluginsClient = Record;"); + } else { + lines.push("export type PluginsClient = {"); + for (const source of opts.depSources) { + const key = /^[$A-Z_][0-9A-Z_$]*$/i.test(source.key) + ? source.key + : JSON.stringify(source.key); + lines.push(` ${key}: ClientFactory<${source.importName}>;`); + } + lines.push("};"); + } + + mkdirSync(dirname(targetPath), { recursive: true }); + writeFileIfChanged(targetPath, `${lines.join("\n")}\n`); +} + +export function writeGeneratedFiles(opts: { configDir: string; sources: ContractSource[]; pluginKeys: string[]; authSource: ContractSource | null; authExportPath?: string | null; + apiDependsOn?: string[]; + pluginDependsOn?: Record; }) { const hasLocalApiWorkspace = existsSync(join(opts.configDir, "api", "src")); const baseSource = opts.sources.find((source) => source.key === "api"); @@ -389,22 +439,24 @@ function writeGeneratedFiles(opts: { writeFileIfChanged(uiContractPath, `${uiLines.join("\n")}\n`); // --- Generate api/src/lib/plugins-types.gen.ts --- - // Includes both plugin contracts AND auth as a unified PluginsClient type + // Filtered by apiDependsOn when explicit; includes all plugins + auth when implicit if (hasLocalApiWorkspace) { const pluginsClientPath = join(opts.configDir, "api", "src", "lib", "plugins-types.gen.ts"); const pluginsClientLines: string[] = []; - for (const source of pluginSources) { - const importPath = toImportPath(pluginsClientPath, source.sourceFilePath); - pluginsClientLines.push( - `import type { ContractType as ${source.importName} } from "${importPath}";`, - ); + const allPluginSources = [...pluginSources]; + if (opts.authSource) { + allPluginSources.push({ ...opts.authSource, key: "auth" }); } - if (opts.authSource) { - const authImportPath = toImportPath(pluginsClientPath, opts.authSource.sourceFilePath); + const apiDepSources = opts.apiDependsOn?.length + ? allPluginSources.filter((s) => opts.apiDependsOn!.includes(s.key)) + : allPluginSources; + + for (const source of apiDepSources) { + const importPath = toImportPath(pluginsClientPath, source.sourceFilePath); pluginsClientLines.push( - `import type { ContractType as ${opts.authSource.importName} } from "${authImportPath}";`, + `import type { ContractType as ${source.importName} } from "${importPath}";`, ); } @@ -416,16 +468,11 @@ function writeGeneratedFiles(opts: { ); pluginsClientLines.push(""); - const allPluginSources = [...pluginSources]; - if (opts.authSource) { - allPluginSources.push({ ...opts.authSource, key: "auth" }); - } - - if (allPluginSources.length === 0) { + if (apiDepSources.length === 0) { pluginsClientLines.push("export type PluginsClient = Record;"); } else { pluginsClientLines.push("export type PluginsClient = {"); - for (const source of allPluginSources) { + for (const source of apiDepSources) { const key = /^[$A-Z_][0-9A-Z_$]*$/i.test(source.key) ? source.key : JSON.stringify(source.key); @@ -438,6 +485,27 @@ function writeGeneratedFiles(opts: { writeFileIfChanged(pluginsClientPath, `${pluginsClientLines.join("\n")}\n`); } + // --- Generate per-plugin plugins-client.gen.ts --- + const allSourcesForLookup = [...pluginSources]; + if (opts.authSource) { + allSourcesForLookup.push({ ...opts.authSource, key: "auth" }); + } + + for (const pluginKey of opts.pluginKeys) { + const deps = opts.pluginDependsOn?.[pluginKey]; + if (!deps?.length) continue; + + const depSources = deps + .map((depKey) => allSourcesForLookup.find((s) => s.key === depKey)) + .filter((s): s is ContractSource => Boolean(s)); + + writePluginClientGen({ + configDir: opts.configDir, + pluginKey, + depSources, + }); + } + // --- Generate */src/lib/auth-types.gen.ts --- const authTypeTargets = [join(opts.configDir, "ui", "src", "lib", "auth-types.gen.ts")]; const apiLibDir = join(opts.configDir, "api", "src", "lib"); @@ -658,12 +726,21 @@ export async function syncApiContractBridge(opts: { .filter(([key]) => !excludedPluginKeys.has(key)) .map(([key]) => key); + const pluginDependsOn: Record = {}; + for (const [key, plugin] of pluginEntries) { + if (!excludedPluginKeys.has(key) && plugin.dependsOn?.length) { + pluginDependsOn[key] = plugin.dependsOn; + } + } + writeGeneratedFiles({ configDir: opts.configDir, sources, pluginKeys: allPluginKeys, authSource, authExportPath, + apiDependsOn: opts.runtimeConfig.api.dependsOn, + pluginDependsOn, }); if (opts.runtimeConfig.api.source !== "local") { diff --git a/packages/everything-dev/src/app.ts b/packages/everything-dev/src/app.ts index 25f404c4..e3055acb 100644 --- a/packages/everything-dev/src/app.ts +++ b/packages/everything-dev/src/app.ts @@ -91,7 +91,7 @@ export function detectLocalPackages( return packages; } -export function buildRuntimeConfig( +export async function buildRuntimeConfig( bosConfig: BosConfig, options: { hostSource?: "local" | "remote"; @@ -102,7 +102,7 @@ export function buildRuntimeConfig( env?: "development" | "production"; plugins?: Record; }, -): RuntimeConfig { +): Promise { return configBuildRuntimeConfig(bosConfig, getProjectRoot(), options.env ?? "development", { hostSource: options.hostSource, uiSource: options.uiSource, diff --git a/packages/everything-dev/src/cli/init.ts b/packages/everything-dev/src/cli/init.ts index 5ceeaf84..1fd382c8 100644 --- a/packages/everything-dev/src/cli/init.ts +++ b/packages/everything-dev/src/cli/init.ts @@ -534,8 +534,8 @@ export function buildChildRootScripts(sections: { changeset: "changeset", version: "changeset version", release: "echo 'Packages versioned - app release handled by workflow'", - postinstall: "bos types gen || true", - "types:gen": "bos types gen", + postinstall: "node node_modules/.bin/bos types gen || true", + "types:gen": "node node_modules/.bin/bos types gen", bos: "bos", }; @@ -960,12 +960,12 @@ export type InferOutput<_TRoute extends string> = any; for (const plugin of opts.plugins ?? []) { const pluginSrcDir = join(destination, "plugins", plugin, "src"); const pluginIndexPath = join(pluginSrcDir, "index.ts"); - const pluginClientGenPath = join(pluginSrcDir, "plugins-client.gen.ts"); + const pluginClientGenPath = join(pluginSrcDir, "lib", "plugins-client.gen.ts"); if (!existsSync(pluginIndexPath) || existsSync(pluginClientGenPath)) { continue; } const pluginIndex = readFileSync(pluginIndexPath, "utf-8"); - if (!pluginIndex.includes("./plugins-client.gen")) { + if (!pluginIndex.includes("./lib/plugins-client.gen")) { continue; } writeFileSync(pluginClientGenPath, "export type PluginsClient = Record;\n"); @@ -1065,19 +1065,13 @@ export async function runTypesGen( remotePlugins?: string[]; }, ): Promise { - const localBosBin = join(destination, "node_modules", ".bin", "bos"); - if (existsSync(localBosBin)) { - const args = ["types", "gen"]; + const bosModule = join(destination, "node_modules", "everything-dev", "dist", "cli.mjs"); + if (existsSync(bosModule)) { + const args = [bosModule, "types", "gen"]; if (opts?.remotePlugins && opts.remotePlugins.length > 0) { args.push("--remote-plugins", opts.remotePlugins.join(",")); } - await runWithProgress( - "node_modules/.bin/bos", - args, - destination, - opts?.spinner, - "Generating types", - ); + await runWithProgress(process.execPath, args, destination, opts?.spinner, "Generating types"); return; } diff --git a/packages/everything-dev/src/config.ts b/packages/everything-dev/src/config.ts index 53da1bcd..f9ac3a56 100644 --- a/packages/everything-dev/src/config.ts +++ b/packages/everything-dev/src/config.ts @@ -1,5 +1,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, isAbsolute, join, resolve } from "node:path"; +import { fetchApiPluginManifest } from "./api-contract"; +import { manifestPluginsToNodes } from "./dag"; import { fetchBosConfigFromFastKv } from "./fastkv"; import { fetchJsonOrNull } from "./http-client"; import { @@ -21,6 +23,7 @@ import type { JsonValue, PluginEntryValue, RuntimeConfig, + RuntimeDependencyNode, RuntimePluginConfig, } from "./types"; import { BosConfigSchema } from "./types"; @@ -203,7 +206,7 @@ export async function loadResolvedConfig(options?: { runtimeEnv, options?.remotePlugins, ); - const runtime = buildRuntimeConfig(config, baseDir, runtimeEnv, { + const runtime = await buildRuntimeConfig(config, baseDir, runtimeEnv, { plugins: pluginRuntime, }); const warnings = drainConfigWarnings(); @@ -711,12 +714,12 @@ export interface BuildRuntimeConfigOptions { proxy?: string; } -export function buildRuntimeConfig( +export async function buildRuntimeConfig( config: BosConfig, baseDir: string, env: BosEnv, options?: BuildRuntimeConfigOptions, -): RuntimeConfig { +): Promise { const uiConfig = config.app.ui; const apiConfig = config.app.api; const authConfig = config.app.auth; @@ -778,7 +781,7 @@ export function buildRuntimeConfig( const apiIsRemote = apiRuntime.source === "remote"; const resolvedApiName = resolvePluginRuntimeName(apiConfig.name, apiRuntime.localPath, "api"); - return { + const result: RuntimeConfig = { env, account: config.account, domain: config.domain, @@ -842,6 +845,65 @@ export function buildRuntimeConfig( plugins: options?.plugins && Object.keys(options.plugins).length > 0 ? options.plugins : undefined, }; + + let manifestNodes: RuntimeDependencyNode[] = []; + const manifestPluginEntries: Array<{ key: string; config: RuntimePluginConfig }> = []; + + if (result.api.source === "remote" && result.api.url) { + try { + const manifest = await fetchApiPluginManifest(result.api.url); + if (manifest.plugins?.length) { + manifestNodes = manifestPluginsToNodes(manifest.plugins); + for (const node of manifestNodes) { + if (!result.plugins?.[node.key]) { + manifestPluginEntries.push({ + key: node.key, + config: { + name: node.name, + url: node.url, + entry: node.entry, + source: "remote", + dependsOn: node.dependsOn, + secrets: node.secrets, + variables: node.variables, + }, + }); + if (node.secrets) { + for (const secretName of node.secrets) { + if (!process.env[secretName]) { + console.warn( + `[Config] Plugin "${node.key}" (discovered from manifest) expects secret "${secretName}" but it is not set in the environment.`, + ); + } + } + } + } + } + } + if (manifest.dependsOn?.length) { + const existing = new Set(result.api.dependsOn ?? []); + for (const dep of manifest.dependsOn) { + if (!existing.has(dep)) { + result.api.dependsOn = [...(result.api.dependsOn ?? []), dep]; + } + } + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`[Config] Failed to fetch API plugin manifest for discovery: ${message}`); + } + } + + if (manifestPluginEntries.length > 0) { + if (!result.plugins) result.plugins = {}; + for (const { key, config } of manifestPluginEntries) { + if (!result.plugins[key]) { + result.plugins[key] = config; + } + } + } + + return result; } async function loadConfigFile(configPath: string, baseDir: string): Promise { @@ -1062,6 +1124,7 @@ function buildRuntimePluginConfig( } : undefined, routes, + dependsOn: source.dependsOn ? normalizeStringArray(source.dependsOn) : undefined, }; } diff --git a/packages/everything-dev/src/dag.ts b/packages/everything-dev/src/dag.ts new file mode 100644 index 00000000..eed6300a --- /dev/null +++ b/packages/everything-dev/src/dag.ts @@ -0,0 +1,235 @@ +import type { JsonObject, RuntimeConfig, RuntimeDependencyNode } from "./types"; + +export interface DependencyDAG { + nodes: Map; + sorted: string[]; +} + +export function normalizeToNodes(config: RuntimeConfig): Map { + const nodes = new Map(); + + if (config.api?.url) { + nodes.set("api", { + key: "api", + kind: "api", + name: config.api.name, + url: config.api.url, + entry: config.api.entry, + source: config.api.source, + localPath: config.api.localPath, + port: config.api.port, + proxy: config.api.proxy, + variables: config.api.variables, + secrets: config.api.secrets, + integrity: config.api.integrity, + shared: config.api.shared, + dependsOn: config.api.dependsOn, + sourceOrigin: "config", + singletonKey: `api:${config.api.url}`, + }); + } + + if (config.auth?.url) { + nodes.set("auth", { + key: "auth", + kind: "auth", + name: config.auth.name, + url: config.auth.url, + entry: config.auth.entry, + source: config.auth.source, + extendsRef: config.auth.extendsRef, + localPath: config.auth.localPath, + port: config.auth.port, + proxy: config.auth.proxy, + variables: config.auth.variables, + secrets: config.auth.secrets, + integrity: config.auth.integrity, + shared: config.auth.shared, + dependsOn: config.auth.dependsOn, + sourceOrigin: "config", + singletonKey: `auth:${config.auth.url}`, + }); + } + + if (config.ui?.url) { + nodes.set("ui", { + key: "ui", + kind: "ui", + name: config.ui.name, + url: config.ui.url, + entry: config.ui.entry, + source: config.ui.source, + localPath: config.ui.localPath, + port: config.ui.port, + integrity: config.ui.integrity, + dependsOn: config.ui.dependsOn, + sourceOrigin: "config", + singletonKey: `ui:${config.ui.url}`, + }); + } + + for (const [key, plugin] of Object.entries(config.plugins ?? {})) { + if (!plugin.url) continue; + nodes.set(key, { + key, + kind: "plugin", + name: plugin.name, + url: plugin.url, + entry: plugin.entry, + source: plugin.source, + extendsRef: plugin.extendsRef, + localPath: plugin.localPath, + port: plugin.port, + proxy: plugin.proxy, + variables: plugin.variables, + secrets: plugin.secrets, + integrity: plugin.integrity, + shared: plugin.shared, + ui: plugin.ui, + routes: plugin.routes, + dependsOn: plugin.dependsOn, + sourceOrigin: "config", + singletonKey: `plugin:${key}:${plugin.url}`, + }); + } + + return nodes; +} + +export function manifestPluginsToNodes( + plugins: Array<{ + key: string; + name: string; + url: string; + dependsOn?: string[]; + secrets?: string[]; + variables?: JsonObject; + }>, +): RuntimeDependencyNode[] { + if (!plugins?.length) return []; + return plugins.map((p) => ({ + key: p.key, + kind: "plugin" as const, + name: p.name, + url: p.url, + entry: `${p.url}/mf-manifest.json`, + source: "remote" as const, + dependsOn: p.dependsOn, + secrets: p.secrets, + variables: p.variables, + sourceOrigin: "manifest" as const, + singletonKey: `plugin:${p.key}:${p.url}`, + })); +} + +export function buildDependencyDAG(config: RuntimeConfig): DependencyDAG { + const nodes = + config.nodes && Object.keys(config.nodes).length > 0 + ? new Map(Object.entries(config.nodes)) + : normalizeToNodes(config); + const sorted = topologicalSort(nodes); + return { nodes, sorted }; +} + +export function getImplicitApiDependencies(nodes: Map): string[] { + return [...nodes.values()].filter((n) => n.kind !== "api" && n.kind !== "ui").map((n) => n.key); +} + +export function topologicalSort(nodes: Map): string[] { + const inDegree = new Map(); + const adjacency = new Map>(); + + for (const key of nodes.keys()) { + inDegree.set(key, 0); + adjacency.set(key, new Set()); + } + + for (const [key, node] of nodes) { + for (const dep of node.dependsOn ?? []) { + if (nodes.has(dep)) { + adjacency.get(dep)!.add(key); + inDegree.set(key, (inDegree.get(key) ?? 0) + 1); + } + } + } + + const apiNode = nodes.get("api"); + if (apiNode && !apiNode.dependsOn?.length) { + for (const depKey of getImplicitApiDependencies(nodes)) { + if (!adjacency.get(depKey)!.has("api")) { + adjacency.get(depKey)!.add("api"); + inDegree.set("api", (inDegree.get("api") ?? 0) + 1); + } + } + } + + const queue: string[] = []; + for (const [key, degree] of inDegree) { + if (degree === 0) queue.push(key); + } + queue.sort(); + + const result: string[] = []; + while (queue.length > 0) { + const current = queue.shift()!; + result.push(current); + const neighbors = adjacency.get(current); + if (neighbors) { + for (const neighbor of neighbors) { + const newDegree = (inDegree.get(neighbor) ?? 0) - 1; + inDegree.set(neighbor, newDegree); + if (newDegree === 0) { + const insertPos = queue.findIndex((k) => k > neighbor); + if (insertPos === -1) queue.push(neighbor); + else queue.splice(insertPos, 0, neighbor); + } + } + } + } + + if (result.length !== nodes.size) { + const cyclic = [...nodes.keys()].filter((k) => !result.includes(k)); + throw new Error( + `Circular dependency detected among: ${cyclic.join(" -> ")}. Check dependsOn declarations in bos.config.json.`, + ); + } + + return result; +} + +export function mergeManifestNodes( + configNodes: Map, + manifestNodes: RuntimeDependencyNode[], +): Map { + const merged = new Map(configNodes); + + for (const node of manifestNodes) { + if (merged.has(node.key) && merged.get(node.key)!.sourceOrigin === "config") { + continue; + } + merged.set(node.key, node); + } + + return merged; +} + +export function getDependenciesForNode( + node: RuntimeDependencyNode, + allNodes: Map, +): RuntimeDependencyNode[] { + if (node.kind === "api" && !node.dependsOn?.length) { + return getImplicitApiDependencies(allNodes) + .map((k) => allNodes.get(k)) + .filter(Boolean) as RuntimeDependencyNode[]; + } + + const explicitDeps = (node.dependsOn ?? []) + .map((k) => allNodes.get(k)) + .filter(Boolean) as RuntimeDependencyNode[]; + + return explicitDeps; +} + +export function getSingletonKey(node: RuntimeDependencyNode): string { + return node.singletonKey ?? `${node.kind}:${node.key}:${node.url}`; +} diff --git a/packages/everything-dev/src/plugin.ts b/packages/everything-dev/src/plugin.ts index 57fe9696..aa2a623d 100644 --- a/packages/everything-dev/src/plugin.ts +++ b/packages/everything-dev/src/plugin.ts @@ -658,7 +658,7 @@ export default createPlugin({ } suppressWarnings(); - const developmentRuntime = buildRuntimeConfig(deps.bosConfig, { + const developmentRuntime = await buildRuntimeConfig(deps.bosConfig, { uiSource, apiSource, authSource, @@ -827,7 +827,7 @@ export default createPlugin({ "production", ); suppressWarnings(); - const runtimeConfig = buildRuntimeConfig(config, { + const runtimeConfig = await buildRuntimeConfig(config, { uiSource: "remote", apiSource: "remote", authSource: "remote", @@ -997,7 +997,7 @@ export default createPlugin({ } suppressWarnings(); - const runtimeConfig = buildRuntimeConfig(deps.bosConfig, { + const runtimeConfig = await buildRuntimeConfig(deps.bosConfig, { uiSource: deps.bosConfig.app.ui?.development ? "local" : "remote", apiSource: deps.bosConfig.app.api?.development ? "local" : "remote", authSource: deps.bosConfig.app.auth?.development ? "local" : "remote", @@ -1689,6 +1689,19 @@ export default createPlugin({ if (existsSync(join(projectDir, "host", "src"))) { generated.push("host/src/lib/auth-types.gen.ts"); } + for (const [key, _plugin] of pluginEntries) { + const pluginSrc = join( + projectDir, + "plugins", + key, + "src", + "lib", + "plugins-client.gen.ts", + ); + if (existsSync(pluginSrc)) { + generated.push(`plugins/${key}/src/lib/plugins-client.gen.ts`); + } + } return { status: "success" as const, @@ -1717,6 +1730,12 @@ export default createPlugin({ if (existsSync(join(projectDir, "host", "src"))) { generated.push("host/src/lib/auth-types.gen.ts"); } + for (const [key, _plugin] of Object.entries(refreshed.runtime.plugins ?? {})) { + const pluginSrc = join(projectDir, "plugins", key, "src", "lib", "plugins-client.gen.ts"); + if (existsSync(pluginSrc)) { + generated.push(`plugins/${key}/src/lib/plugins-client.gen.ts`); + } + } const contractStatus = artifacts?.contractStatus ?? []; const fetched: string[] = []; diff --git a/packages/everything-dev/src/types.ts b/packages/everything-dev/src/types.ts index 096d0d60..8a3ae256 100644 --- a/packages/everything-dev/src/types.ts +++ b/packages/everything-dev/src/types.ts @@ -83,6 +83,7 @@ export const BosPluginRefSchema = ComposableAppEntrySchema.extend({ version: z.string().optional(), app: z.record(z.string(), z.unknown()).optional(), plugins: z.record(z.string(), z.unknown()).optional(), + dependsOn: z.array(z.string()).optional(), }); export type BosPluginRef = z.infer; export type PluginEntryValue = string | BosPluginRef; @@ -114,9 +115,36 @@ export const RuntimePluginConfigSchema = z.object({ shared: SharedDepMapSchema.optional(), ui: PluginRuntimeUiSchema.optional(), routes: z.array(z.string()).optional(), + dependsOn: z.array(z.string()).optional(), }); export type RuntimePluginConfig = z.infer; +export const DependencyNodeKindSchema = z.enum(["api", "auth", "ui", "plugin"]); +export type DependencyNodeKind = z.infer; + +export const RuntimeDependencyNodeSchema = z.object({ + key: z.string(), + kind: DependencyNodeKindSchema, + name: z.string(), + url: z.string(), + entry: z.string(), + source: SourceModeSchema, + dependsOn: z.array(z.string()).optional(), + extendsRef: z.string().optional(), + localPath: z.string().optional(), + port: z.number().optional(), + proxy: z.string().optional(), + variables: JsonObjectSchema.optional(), + secrets: z.array(z.string()).optional(), + integrity: z.string().optional(), + shared: SharedDepMapSchema.optional(), + ui: PluginRuntimeUiSchema.optional(), + routes: z.array(z.string()).optional(), + sourceOrigin: z.enum(["config", "manifest"]).optional(), + singletonKey: z.string().optional(), +}); +export type RuntimeDependencyNode = z.infer; + export const UiConfigSchema = z .object({ name: z.string().optional(), @@ -268,6 +296,7 @@ export const RuntimeConfigSchema = z.object({ port: z.number().optional(), ssrUrl: z.string().optional(), ssrIntegrity: z.string().optional(), + dependsOn: z.array(z.string()).optional(), }), api: FederationEntrySchema.extend({ localPath: z.string().optional(), @@ -276,6 +305,7 @@ export const RuntimeConfigSchema = z.object({ variables: JsonObjectSchema.optional(), secrets: z.array(z.string()).optional(), shared: SharedDepMapSchema.optional(), + dependsOn: z.array(z.string()).optional(), }), auth: FederationEntrySchema.extend({ extendsRef: z.string().optional(), @@ -285,8 +315,10 @@ export const RuntimeConfigSchema = z.object({ variables: JsonObjectSchema.optional(), secrets: z.array(z.string()).optional(), shared: SharedDepMapSchema.optional(), + dependsOn: z.array(z.string()).optional(), }).optional(), plugins: z.record(z.string(), RuntimePluginConfigSchema).optional(), + nodes: z.record(z.string(), RuntimeDependencyNodeSchema).optional(), }); export type RuntimeConfig = z.infer; diff --git a/packages/everything-dev/tests/integration/init.typecheck.test.ts b/packages/everything-dev/tests/integration/init.typecheck.test.ts index 35926215..307fabb4 100644 --- a/packages/everything-dev/tests/integration/init.typecheck.test.ts +++ b/packages/everything-dev/tests/integration/init.typecheck.test.ts @@ -116,7 +116,7 @@ function isUnexpectedError(error: string): boolean { if (error.includes(".gen.ts")) return true; - return true; + return false; } describe("bos init — typecheck", () => { @@ -159,16 +159,16 @@ describe("bos init — typecheck", () => { expect(pkg.dependencies?.["@better-auth/core"]).toBe("catalog:"); }); - it("sets postinstall to 'bos types gen || true'", async () => { + it("sets postinstall to 'node node_modules/.bin/bos types gen || true'", async () => { const pkgPath = join(testDir, "package.json"); const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { scripts?: Record }; - expect(pkg.scripts?.postinstall).toBe("bos types gen || true"); + expect(pkg.scripts?.postinstall).toBe("node node_modules/.bin/bos types gen || true"); }); - it("sets types:gen to 'bos types gen'", async () => { + it("sets types:gen to 'node node_modules/.bin/bos types gen'", async () => { const pkgPath = join(testDir, "package.json"); const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { scripts?: Record }; - expect(pkg.scripts?.["types:gen"]).toBe("bos types gen"); + expect(pkg.scripts?.["types:gen"]).toBe("node node_modules/.bin/bos types gen"); }); it("installs dependencies", async () => { diff --git a/packages/everything-dev/tests/integration/personalize-config.test.ts b/packages/everything-dev/tests/integration/personalize-config.test.ts index dacd473c..5305c3ac 100644 --- a/packages/everything-dev/tests/integration/personalize-config.test.ts +++ b/packages/everything-dev/tests/integration/personalize-config.test.ts @@ -144,8 +144,8 @@ describe("personalizeConfig with real root config", () => { expect(pkg.dependencies?.["every-plugin"]).toBe("catalog:"); expect(pkg.devDependencies?.["everything-dev"]).toBeUndefined(); expect(pkg.devDependencies?.["every-plugin"]).toBeUndefined(); - expect(pkg.scripts?.postinstall).toBe("bos types gen || true"); - expect(pkg.scripts?.["types:gen"]).toBe("bos types gen"); + expect(pkg.scripts?.postinstall).toBe("node node_modules/.bin/bos types gen || true"); + expect(pkg.scripts?.["types:gen"]).toBe("node node_modules/.bin/bos types gen"); expect(pkg.scripts?.bos).toBe("bos"); expect(pkg.workspaces?.packages).toEqual(expect.arrayContaining(["ui", "api", "plugins/*"])); expect(pkg.workspaces?.packages).toHaveLength(3); diff --git a/packages/everything-dev/tests/integration/plugin-ui-runtime.test.ts b/packages/everything-dev/tests/integration/plugin-ui-runtime.test.ts index 1a329d96..4bf75cf6 100644 --- a/packages/everything-dev/tests/integration/plugin-ui-runtime.test.ts +++ b/packages/everything-dev/tests/integration/plugin-ui-runtime.test.ts @@ -85,7 +85,7 @@ describe("plugin UI runtime config", () => { baseDir, "development" as BosEnv, ); - const runtime = buildRuntimeConfig(config, baseDir, "development" as BosEnv, { + const runtime = await buildRuntimeConfig(config, baseDir, "development" as BosEnv, { plugins: pluginRuntime, }); diff --git a/packages/everything-dev/tests/unit/api-contract-gen.test.ts b/packages/everything-dev/tests/unit/api-contract-gen.test.ts new file mode 100644 index 00000000..b3a9dabc --- /dev/null +++ b/packages/everything-dev/tests/unit/api-contract-gen.test.ts @@ -0,0 +1,136 @@ +import { + existsSync, + writeFileSync as fsWriteFileSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +let testDir: string; + +afterEach(() => { + if (testDir) rmSync(testDir, { recursive: true, force: true }); +}); + +function makeContractSource(key: string, filePath: string, importName?: string) { + return { key, sourceFilePath: filePath, importName: importName ?? `${key}Contract` }; +} + +function writeFile(path: string, content: string) { + mkdirSync(dirname(path), { recursive: true }); + fsWriteFileSync(path, content, "utf-8"); +} + +describe("writeGeneratedFiles — apiDependsOn filtering", () => { + it("includes all plugins + auth when apiDependsOn is not set", async () => { + const { writeGeneratedFiles } = await import("../../src/api-contract"); + testDir = mkdtempSync(join(tmpdir(), "api-contract-test-")); + + const apiSrc = join(testDir, "api", "src"); + const uiLib = join(testDir, "ui", "src", "lib"); + const apiLib = join(testDir, "api", "src", "lib"); + const authSrc = join(testDir, "plugins", "auth", "src"); + const pluginASrc = join(testDir, "plugins", "pluginA", "src"); + const pluginBSrc = join(testDir, "plugins", "pluginB", "src"); + + const contractPath = join(apiSrc, "contract.ts"); + writeFile(contractPath, "export type ContractType = { ping: string };"); + + const resultPath = writeGeneratedFiles({ + configDir: testDir, + sources: [ + makeContractSource("api", contractPath), + makeContractSource("pluginA", join(pluginASrc, "contract.ts")), + makeContractSource("pluginB", join(pluginBSrc, "contract.ts")), + ], + pluginKeys: ["pluginA", "pluginB"], + authSource: makeContractSource("auth", join(authSrc, "contract.ts"), "authContract"), + apiDependsOn: undefined, + }); + + expect(resultPath).toBe(join(uiLib, "api-types.gen.ts")); + + const pluginsTypes = readFileSync(join(apiLib, "plugins-types.gen.ts"), "utf-8"); + expect(pluginsTypes).toContain("authContract"); + expect(pluginsTypes).toContain("pluginAContract"); + expect(pluginsTypes).toContain("pluginBContract"); + }); + + it("filters plugins-types.gen.ts when apiDependsOn is set", async () => { + const { writeGeneratedFiles } = await import("../../src/api-contract"); + testDir = mkdtempSync(join(tmpdir(), "api-contract-test-")); + + const apiSrc = join(testDir, "api", "src"); + const apiLib = join(testDir, "api", "src", "lib"); + const authSrc = join(testDir, "plugins", "auth", "src"); + const pluginASrc = join(testDir, "plugins", "pluginA", "src"); + const pluginBSrc = join(testDir, "plugins", "pluginB", "src"); + + const contractPath = join(apiSrc, "contract.ts"); + writeFile(contractPath, "export type ContractType = { ping: string };"); + + writeGeneratedFiles({ + configDir: testDir, + sources: [ + makeContractSource("api", contractPath), + makeContractSource("pluginA", join(pluginASrc, "contract.ts")), + makeContractSource("pluginB", join(pluginBSrc, "contract.ts")), + ], + pluginKeys: ["pluginA", "pluginB"], + authSource: makeContractSource("auth", join(authSrc, "contract.ts"), "authContract"), + apiDependsOn: ["pluginA", "auth"], + }); + + const pluginsTypes = readFileSync(join(apiLib, "plugins-types.gen.ts"), "utf-8"); + expect(pluginsTypes).toContain("pluginAContract"); + expect(pluginsTypes).toContain("authContract"); + expect(pluginsTypes).not.toContain("pluginBContract"); + }); + + it("generates per-plugin plugins-client.gen.ts from pluginDependsOn", async () => { + const { writeGeneratedFiles } = await import("../../src/api-contract"); + testDir = mkdtempSync(join(tmpdir(), "api-contract-test-")); + + const apiSrc = join(testDir, "api", "src"); + const authSrc = join(testDir, "plugins", "auth", "src"); + const pluginASrc = join(testDir, "plugins", "pluginA", "src"); + const pluginBSrc = join(testDir, "plugins", "pluginB", "src"); + + const contractPath = join(apiSrc, "contract.ts"); + writeFile(contractPath, "export type ContractType = { ping: string };"); + mkdirSync(pluginASrc, { recursive: true }); + mkdirSync(pluginBSrc, { recursive: true }); + + writeGeneratedFiles({ + configDir: testDir, + sources: [ + makeContractSource("api", contractPath), + makeContractSource("pluginA", join(pluginASrc, "contract.ts")), + makeContractSource("pluginB", join(pluginBSrc, "contract.ts")), + ], + pluginKeys: ["pluginA", "pluginB"], + authSource: makeContractSource("auth", join(authSrc, "contract.ts"), "authContract"), + apiDependsOn: ["pluginA", "pluginB"], + pluginDependsOn: { + pluginA: ["auth"], + pluginB: ["pluginA"], + }, + }); + + const pluginAClientPath = join(pluginASrc, "lib", "plugins-client.gen.ts"); + expect(existsSync(pluginAClientPath)).toBe(true); + const pluginAClient = readFileSync(pluginAClientPath, "utf-8"); + expect(pluginAClient).toContain("authContract"); + expect(pluginAClient).not.toContain("pluginBContract"); + + const pluginBClientPath = join(pluginBSrc, "lib", "plugins-client.gen.ts"); + expect(existsSync(pluginBClientPath)).toBe(true); + const pluginBClient = readFileSync(pluginBClientPath, "utf-8"); + expect(pluginBClient).toContain("pluginAContract"); + expect(pluginBClient).not.toContain("authContract"); + }); +}); diff --git a/packages/everything-dev/tests/unit/dependency-dag.test.ts b/packages/everything-dev/tests/unit/dependency-dag.test.ts new file mode 100644 index 00000000..de497e03 --- /dev/null +++ b/packages/everything-dev/tests/unit/dependency-dag.test.ts @@ -0,0 +1,630 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + buildDependencyDAG, + getDependenciesForNode, + getImplicitApiDependencies, + getSingletonKey, + manifestPluginsToNodes, + mergeManifestNodes, + normalizeToNodes, + topologicalSort, +} from "../../src/dag"; +import type { RuntimeConfig, RuntimeDependencyNode } from "../../src/types"; + +const { fetchApiPluginManifestMock } = vi.hoisted(() => ({ + fetchApiPluginManifestMock: vi.fn().mockRejectedValue(new Error("not mocked")), +})); + +vi.mock("../../src/api-contract", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchApiPluginManifest: fetchApiPluginManifestMock, + }; +}); + +function makeRuntimeConfig(overrides?: Partial): RuntimeConfig { + return { + env: "development", + account: "test.near", + networkId: "testnet", + host: { + name: "host", + url: "http://localhost:3000", + entry: "http://localhost:3000/mf-manifest.json", + source: "local", + localPath: "/host", + }, + ui: { + name: "ui", + url: "http://localhost:3003", + entry: "http://localhost:3003/mf-manifest.json", + source: "local", + localPath: "/ui", + }, + api: { + name: "api", + url: "http://localhost:3001", + entry: "http://localhost:3001/mf-manifest.json", + source: "local", + localPath: "/api", + }, + plugins: {}, + ...overrides, + }; +} + +function makePluginEntry( + key: string, + url: string, + extra?: Partial, +): [string, RuntimeDependencyNode] { + return [ + key, + { + key, + kind: "plugin", + name: key, + url, + entry: `${url}/mf-manifest.json`, + source: "remote", + sourceOrigin: "config", + singletonKey: `plugin:${key}:${url}`, + ...extra, + }, + ]; +} + +function makePluginNode( + key: string, + url: string, + extra?: Partial, +): RuntimeDependencyNode { + return makePluginEntry(key, url, extra)[1]; +} + +function makeApiNode(extra?: Partial): [string, RuntimeDependencyNode] { + return [ + "api", + { + key: "api", + kind: "api", + name: "api", + url: "http://api", + entry: "http://api/mf-manifest.json", + source: "local", + sourceOrigin: "config", + singletonKey: "api:http://api", + ...extra, + }, + ]; +} + +function makeUiNode(extra?: Partial): [string, RuntimeDependencyNode] { + return [ + "ui", + { + key: "ui", + kind: "ui", + name: "ui", + url: "http://ui", + entry: "http://ui/mf-manifest.json", + source: "local", + sourceOrigin: "config", + singletonKey: "ui:http://ui", + ...extra, + }, + ]; +} + +describe("normalizeToNodes", () => { + it("converts api, auth, ui, and plugins into unified nodes", () => { + const config = makeRuntimeConfig({ + auth: { + name: "auth", + url: "http://localhost:3002", + entry: "http://localhost:3002/mf-manifest.json", + source: "local", + localPath: "/auth", + }, + plugins: { + apps: { + name: "apps", + url: "http://localhost:3010", + entry: "http://localhost:3010/mf-manifest.json", + source: "local", + localPath: "/plugins/apps", + }, + }, + }); + + const nodes = normalizeToNodes(config); + + expect(nodes.size).toBe(4); + expect(nodes.get("api")?.kind).toBe("api"); + expect(nodes.get("auth")?.kind).toBe("auth"); + expect(nodes.get("ui")?.kind).toBe("ui"); + expect(nodes.get("apps")?.kind).toBe("plugin"); + }); + + it("preserves dependsOn from plugin config", () => { + const config = makeRuntimeConfig({ + plugins: { + myPlugin: { + name: "myPlugin", + url: "http://localhost:3011", + entry: "http://localhost:3011/mf-manifest.json", + source: "local", + localPath: "/plugins/myPlugin", + dependsOn: ["apps", "auth"], + }, + }, + }); + + const nodes = normalizeToNodes(config); + expect(nodes.get("myPlugin")?.dependsOn).toEqual(["apps", "auth"]); + }); + + it("preserves dependsOn from api config", () => { + const config = makeRuntimeConfig({ + api: { + name: "api", + url: "http://localhost:3001", + entry: "http://localhost:3001/mf-manifest.json", + source: "local", + localPath: "/api", + dependsOn: ["apps"], + }, + }); + + const nodes = normalizeToNodes(config); + expect(nodes.get("api")?.dependsOn).toEqual(["apps"]); + }); + + it("skips plugins without a url", () => { + const config = makeRuntimeConfig({ + plugins: { + broken: { + name: "broken", + url: "", + entry: "", + source: "local", + }, + }, + }); + + const nodes = normalizeToNodes(config); + expect(nodes.has("broken")).toBe(false); + }); + + it("sets singletonKey for each node", () => { + const config = makeRuntimeConfig({ + plugins: { + apps: { + name: "apps", + url: "http://localhost:3010", + entry: "http://localhost:3010/mf-manifest.json", + source: "local", + localPath: "/plugins/apps", + }, + }, + }); + + const nodes = normalizeToNodes(config); + expect(nodes.get("api")?.singletonKey).toBe("api:http://localhost:3001"); + expect(nodes.get("apps")?.singletonKey).toBe("plugin:apps:http://localhost:3010"); + }); +}); + +describe("topologicalSort", () => { + it("returns nodes with no dependencies first", () => { + const nodes = new Map([ + makePluginEntry("pluginA", "http://a", { dependsOn: ["pluginB"] }), + makePluginEntry("pluginB", "http://b"), + ]); + + const sorted = topologicalSort(nodes); + expect(sorted.indexOf("pluginB")).toBeLessThan(sorted.indexOf("pluginA")); + }); + + it("handles chains: A depends on B depends on C", () => { + const nodes = new Map([ + makePluginEntry("A", "http://a", { dependsOn: ["B"] }), + makePluginEntry("B", "http://b", { dependsOn: ["C"] }), + makePluginEntry("C", "http://c"), + ]); + + const sorted = topologicalSort(nodes); + expect(sorted).toEqual(["C", "B", "A"]); + }); + + it("throws on circular dependencies", () => { + const nodes = new Map([ + makePluginEntry("A", "http://a", { dependsOn: ["B"] }), + makePluginEntry("B", "http://b", { dependsOn: ["A"] }), + ]); + + expect(() => topologicalSort(nodes)).toThrow(/Circular dependency/); + }); + + it("throws with node names in cycle", () => { + const nodes = new Map([ + makePluginEntry("X", "http://x", { dependsOn: ["Y"] }), + makePluginEntry("Y", "http://y", { dependsOn: ["Z"] }), + makePluginEntry("Z", "http://z", { dependsOn: ["X"] }), + ]); + + expect(() => topologicalSort(nodes)).toThrow(/X.*Y.*Z|Z.*Y.*X/); + }); + + it("ignores dependsOn entries that don't exist in the node set", () => { + const nodes = new Map([ + makePluginEntry("A", "http://a", { dependsOn: ["nonexistent"] }), + ]); + + const sorted = topologicalSort(nodes); + expect(sorted).toEqual(["A"]); + }); + + it("API implicitly depends on all other non-ui nodes when no explicit dependsOn", () => { + const nodes = new Map([ + makeApiNode(), + makePluginEntry("pluginA", "http://a"), + makePluginEntry("pluginB", "http://b"), + makeUiNode(), + ]); + + const sorted = topologicalSort(nodes); + expect(sorted.indexOf("pluginA")).toBeLessThan(sorted.indexOf("api")); + expect(sorted.indexOf("pluginB")).toBeLessThan(sorted.indexOf("api")); + }); + + it("API with explicit dependsOn does NOT get implicit deps", () => { + const nodes = new Map([ + makeApiNode({ dependsOn: ["pluginA"] }), + makePluginEntry("pluginA", "http://a"), + makePluginEntry("pluginB", "http://b"), + ]); + + const sorted = topologicalSort(nodes); + expect(sorted.indexOf("pluginA")).toBeLessThan(sorted.indexOf("api")); + }); + + it("preserves alphabetical order for independent nodes", () => { + const nodes = new Map([ + makePluginEntry("zebra", "http://z"), + makePluginEntry("alpha", "http://a"), + makePluginEntry("middle", "http://m"), + ]); + + const sorted = topologicalSort(nodes); + expect(sorted).toEqual(["alpha", "middle", "zebra"]); + }); +}); + +describe("getImplicitApiDependencies", () => { + it("returns all non-ui nodes for API with no explicit dependsOn", () => { + const nodes = new Map([ + makeApiNode(), + makePluginEntry("pluginA", "http://a"), + makePluginEntry("pluginB", "http://b"), + makeUiNode(), + makePluginEntry("auth", "http://auth", { kind: "auth" }), + ]); + + const deps = getImplicitApiDependencies(nodes); + expect(deps).toEqual(expect.arrayContaining(["pluginA", "pluginB", "auth"])); + expect(deps).not.toContain("api"); + expect(deps).not.toContain("ui"); + }); + + it("returns empty array when only api and ui exist", () => { + const nodes = new Map([makeApiNode(), makeUiNode()]); + + expect(getImplicitApiDependencies(nodes)).toEqual([]); + }); +}); + +describe("buildRuntimeConfig nodes field", () => { + let w: ReturnType; + beforeEach(() => { + w = vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + afterEach(() => w.mockRestore()); + + it("includes nodes populated by normalizeToNodes", async () => { + const { buildRuntimeConfig } = await import("../../src/config"); + const config: RuntimeConfig = { + env: "development", + account: "test.near", + networkId: "testnet", + host: { + name: "host", + url: "http://localhost:3000", + entry: "http://localhost:3000/mf-manifest.json", + source: "local", + localPath: "/host", + }, + ui: { + name: "ui", + url: "http://localhost:3003", + entry: "http://localhost:3003/mf-manifest.json", + source: "local", + localPath: "/ui", + }, + api: { + name: "api", + url: "http://localhost:3001", + entry: "http://localhost:3001/mf-manifest.json", + source: "local", + localPath: "/api", + }, + plugins: {}, + }; + + expect(config.nodes).toBeUndefined(); + + const result = await buildRuntimeConfig( + { + account: "test.near", + extends: "dev.everything.near/dev.everything.dev", + app: { + host: { + name: "host", + development: "http://localhost:3000", + production: "http://host.com", + }, + ui: { name: "ui", development: "http://localhost:3003", production: "http://ui.com" }, + api: { name: "api", development: "http://localhost:3001", production: "http://api.com" }, + }, + } as any, + "/tmp", + "development", + ); + + const dag = buildDependencyDAG(result); + expect(dag.nodes.has("api")).toBe(true); + expect(dag.nodes.get("api")?.kind).toBe("api"); + expect(dag.nodes.has("ui")).toBe(true); + expect(dag.nodes.get("ui")?.kind).toBe("ui"); + }); +}); + +describe("buildDependencyDAG", () => { + it("builds and sorts a complete DAG from RuntimeConfig", () => { + const config = makeRuntimeConfig({ + auth: { + name: "auth", + url: "http://localhost:3002", + entry: "http://localhost:3002/mf-manifest.json", + source: "local", + localPath: "/auth", + }, + plugins: { + apps: { + name: "apps", + url: "http://localhost:3010", + entry: "http://localhost:3010/mf-manifest.json", + source: "local", + localPath: "/plugins/apps", + }, + composite: { + name: "composite", + url: "http://localhost:3011", + entry: "http://localhost:3011/mf-manifest.json", + source: "local", + localPath: "/plugins/composite", + dependsOn: ["apps", "auth"], + }, + }, + }); + + const dag = buildDependencyDAG(config); + expect(dag.nodes.size).toBe(5); + expect(dag.sorted.indexOf("apps")).toBeLessThan(dag.sorted.indexOf("composite")); + expect(dag.sorted.indexOf("auth")).toBeLessThan(dag.sorted.indexOf("composite")); + expect(dag.sorted.indexOf("composite")).toBeLessThan(dag.sorted.indexOf("api")); + }); +}); + +describe("mergeManifestNodes", () => { + it("adds manifest nodes that don't exist in config", () => { + const configNodes = new Map([ + makePluginEntry("apps", "http://apps", { sourceOrigin: "config" }), + ]); + + const manifestNodes = [ + makePluginNode("discovered", "http://discovered", { sourceOrigin: "manifest" }), + ]; + + const merged = mergeManifestNodes(configNodes, manifestNodes); + expect(merged.size).toBe(2); + expect(merged.get("discovered")?.sourceOrigin).toBe("manifest"); + }); + + it("does not override config-declared nodes with manifest nodes", () => { + const configNodes = new Map([ + makePluginEntry("apps", "http://my-custom-apps", { sourceOrigin: "config" }), + ]); + + const manifestNodes = [ + makePluginNode("apps", "http://apps.nearbuilders.org", { sourceOrigin: "manifest" }), + ]; + + const merged = mergeManifestNodes(configNodes, manifestNodes); + expect(merged.get("apps")?.url).toBe("http://my-custom-apps"); + expect(merged.get("apps")?.sourceOrigin).toBe("config"); + }); +}); + +describe("getDependenciesForNode", () => { + it("returns explicit dependsOn entries", () => { + const allNodes = new Map([ + makePluginEntry("A", "http://a"), + makePluginEntry("B", "http://b"), + makePluginEntry("C", "http://c", { dependsOn: ["A", "B"] }), + ]); + + const deps = getDependenciesForNode(allNodes.get("C")!, allNodes); + expect(deps.map((d) => d.key)).toEqual(["A", "B"]); + }); + + it("returns all non-ui siblings for API with no explicit dependsOn", () => { + const allNodes = new Map([ + makeApiNode(), + makePluginEntry("pluginA", "http://a"), + makeUiNode(), + ]); + + const deps = getDependenciesForNode(allNodes.get("api")!, allNodes); + expect(deps.map((d) => d.key)).toEqual(["pluginA"]); + }); + + it("returns only explicit deps for API with explicit dependsOn", () => { + const allNodes = new Map([ + makeApiNode({ dependsOn: ["pluginA"] }), + makePluginEntry("pluginA", "http://a"), + makePluginEntry("pluginB", "http://b"), + ]); + + const deps = getDependenciesForNode(allNodes.get("api")!, allNodes); + expect(deps.map((d) => d.key)).toEqual(["pluginA"]); + }); +}); + +describe("manifestPluginsToNodes", () => { + it("converts manifest plugin entries to nodes with sourceOrigin manifest", () => { + const nodes = manifestPluginsToNodes([ + { key: "discovered", name: "Discovered", url: "http://d.cdn", dependsOn: ["auth"] }, + ]); + + expect(nodes).toHaveLength(1); + expect(nodes[0].kind).toBe("plugin"); + expect(nodes[0].sourceOrigin).toBe("manifest"); + expect(nodes[0].source).toBe("remote"); + expect(nodes[0].entry).toBe("http://d.cdn/mf-manifest.json"); + expect(nodes[0].dependsOn).toEqual(["auth"]); + expect(nodes[0].singletonKey).toBe("plugin:discovered:http://d.cdn"); + }); + + it("passes through secrets and variables", () => { + const nodes = manifestPluginsToNodes([ + { + key: "billing", + name: "Billing", + url: "http://billing.cdn", + secrets: ["STRIPE_KEY"], + variables: { TIMEOUT: 5000 }, + }, + ]); + + expect(nodes[0].secrets).toEqual(["STRIPE_KEY"]); + expect(nodes[0].variables).toEqual({ TIMEOUT: 5000 }); + }); + + it("returns empty array for undefined or empty input", () => { + expect(manifestPluginsToNodes([])).toEqual([]); + expect(manifestPluginsToNodes(undefined as any)).toEqual([]); + }); +}); + +describe("buildDependencyDAG with config.nodes", () => { + it("uses config.nodes when populated instead of normalizing from scratch", () => { + const config = makeRuntimeConfig({ + auth: { + name: "auth", + url: "http://localhost:3002", + entry: "http://localhost:3002/mf-manifest.json", + source: "local", + }, + plugins: { + manifestPlugin: { + name: "manifestPlugin", + url: "http://manifest.cdn", + entry: "http://manifest.cdn/mf-manifest.json", + source: "remote", + dependsOn: ["auth"], + }, + }, + }); + config.nodes = { + api: { + key: "api", + kind: "api", + name: "api", + url: "http://localhost:3001", + entry: "http://localhost:3001/mf-manifest.json", + source: "local", + sourceOrigin: "config", + singletonKey: "api:http://localhost:3001", + }, + auth: { + key: "auth", + kind: "auth", + name: "auth", + url: "http://localhost:3002", + entry: "http://localhost:3002/mf-manifest.json", + source: "local", + sourceOrigin: "config", + singletonKey: "auth:http://localhost:3002", + }, + ui: { + key: "ui", + kind: "ui", + name: "ui", + url: "http://localhost:3003", + entry: "http://localhost:3003/mf-manifest.json", + source: "local", + sourceOrigin: "config", + singletonKey: "ui:http://localhost:3003", + }, + manifestPlugin: { + key: "manifestPlugin", + kind: "plugin", + name: "manifestPlugin", + url: "http://manifest.cdn", + entry: "http://manifest.cdn/mf-manifest.json", + source: "remote", + sourceOrigin: "manifest", + dependsOn: ["auth"], + singletonKey: "plugin:manifestPlugin:http://manifest.cdn", + }, + }; + + const dag = buildDependencyDAG(config); + + expect(dag.nodes.get("manifestPlugin")?.sourceOrigin).toBe("manifest"); + expect(dag.sorted.indexOf("auth")).toBeLessThan(dag.sorted.indexOf("manifestPlugin")); + }); + + it("falls back to normalizeToNodes when config.nodes is empty", () => { + const config = makeRuntimeConfig({ + plugins: { + apps: { + name: "apps", + url: "http://localhost:3010", + entry: "http://localhost:3010/mf-manifest.json", + source: "local", + }, + }, + }); + + const dag = buildDependencyDAG(config); + expect(dag.nodes.get("apps")?.sourceOrigin).toBe("config"); + }); +}); + +describe("getSingletonKey", () => { + it("uses the singletonKey field when present", () => { + const node = makePluginNode("apps", "http://apps", { + singletonKey: "custom:single:key", + }); + expect(getSingletonKey(node)).toBe("custom:single:key"); + }); + + it("falls back to kind:key:url", () => { + const node = makePluginNode("apps", "http://apps"); + node.singletonKey = undefined; + expect(getSingletonKey(node)).toBe("plugin:apps:http://apps"); + }); +}); diff --git a/packages/everything-dev/tests/unit/manifest-stacking.test.ts b/packages/everything-dev/tests/unit/manifest-stacking.test.ts new file mode 100644 index 00000000..c2fe693e --- /dev/null +++ b/packages/everything-dev/tests/unit/manifest-stacking.test.ts @@ -0,0 +1,193 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ApiPluginManifest } from "../../src/api-contract"; +import { buildDependencyDAG } from "../../src/dag"; + +const { fetchApiPluginManifestMock } = vi.hoisted(() => ({ + fetchApiPluginManifestMock: vi.fn(), +})); + +vi.mock("../../src/api-contract", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchApiPluginManifest: fetchApiPluginManifestMock, + }; +}); + +const MANIFEST: ApiPluginManifest = { + schemaVersion: 1, + kind: "every-plugin/manifest", + plugin: { name: "api", version: "1.0.0" }, + runtime: { remoteEntry: "http://api.cdn/remoteEntry.js" }, + plugins: [ + { + key: "discovered", + name: "Discovered Plugin", + url: "http://discovered.cdn", + dependsOn: ["auth"], + secrets: ["STRIPE_KEY"], + variables: { TIMEOUT: 5000 }, + }, + { + key: "pluginA", + name: "PluginA Override Attempt", + url: "http://evil.cdn", + }, + ], + dependsOn: ["pluginA", "auth"], +}; + +function makeRemoteBosConfig() { + return { + account: "test.near", + extends: "dev.everything.near/dev.everything.dev", + app: { + host: { name: "host", development: "http://localhost:3000", production: "http://host.cdn" }, + ui: { name: "ui", development: "http://localhost:3003", production: "http://ui.cdn" }, + api: { name: "api", development: "http://localhost:3001", production: "http://api.cdn" }, + auth: { + name: "auth", + extends: "dev.everything.near/auth", + development: "http://localhost:3002", + production: "http://auth.cdn", + }, + }, + plugins: { + pluginA: { + name: "pluginA", + url: "http://pluginA.cdn", + source: "remote" as const, + }, + }, + } as any; +} + +describe("manifest stacking (one level deep)", () => { + let warnSpy: ReturnType; + + beforeEach(() => { + fetchApiPluginManifestMock.mockClear(); + fetchApiPluginManifestMock.mockResolvedValue(MANIFEST); + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("discovers manifest plugins, merges secrets/variables, respects config-over-manifest, and builds correct DAG", async () => { + const { buildRuntimeConfig } = await import("../../src/config"); + + const result = await buildRuntimeConfig(makeRemoteBosConfig(), "/tmp", "production", { + hostSource: "remote", + uiSource: "remote", + apiSource: "remote", + authSource: "remote", + plugins: { + pluginA: { + name: "pluginA", + url: "http://pluginA.cdn", + entry: "http://pluginA.cdn/mf-manifest.json", + source: "remote", + }, + }, + }); + + expect(result.plugins?.discovered).toBeDefined(); + expect(result.plugins?.discovered.url).toBe("http://discovered.cdn"); + expect(result.plugins?.discovered.source).toBe("remote"); + expect(result.plugins?.discovered.secrets).toEqual(["STRIPE_KEY"]); + expect(result.plugins?.discovered.variables).toEqual({ TIMEOUT: 5000 }); + + expect(result.plugins?.pluginA.url).toBe("http://pluginA.cdn"); + + expect(result.api.dependsOn).toEqual(expect.arrayContaining(["pluginA", "auth"])); + + const dag = buildDependencyDAG(result); + expect(dag.sorted).toContain("discovered"); + expect(dag.sorted.indexOf("auth")).toBeLessThan(dag.sorted.indexOf("discovered")); + expect(dag.nodes.get("discovered")?.kind).toBe("plugin"); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Plugin "discovered"')); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("STRIPE_KEY")); + }); + + it("allows config-declared plugin to override manifest-discovered plugin with custom variables", async () => { + const { buildRuntimeConfig } = await import("../../src/config"); + + const result = await buildRuntimeConfig(makeRemoteBosConfig(), "/tmp", "production", { + hostSource: "remote", + uiSource: "remote", + apiSource: "remote", + authSource: "remote", + plugins: { + discovered: { + name: "discovered", + url: "http://my-discovered.cdn", + entry: "http://my-discovered.cdn/mf-manifest.json", + source: "remote", + variables: { TIMEOUT: 10000 }, + secrets: ["MY_SECRET"], + }, + }, + }); + + expect(result.plugins?.discovered.url).toBe("http://my-discovered.cdn"); + expect(result.plugins?.discovered.variables).toEqual({ TIMEOUT: 10000 }); + }); + + it("gracefully degrades when manifest fetch fails", async () => { + const { buildRuntimeConfig } = await import("../../src/config"); + + fetchApiPluginManifestMock.mockRejectedValue(new Error("Network error")); + + const result = await buildRuntimeConfig(makeRemoteBosConfig(), "/tmp", "production", { + hostSource: "remote", + uiSource: "remote", + apiSource: "remote", + authSource: "remote", + }); + + expect(result.plugins?.discovered).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("Failed to fetch API plugin manifest"), + ); + }); + + it("does not fetch manifest for local API", async () => { + const { buildRuntimeConfig } = await import("../../src/config"); + const { mkdtempSync, mkdirSync } = await import("node:fs"); + const { join } = await import("node:path"); + const { tmpdir } = await import("node:os"); + + const testDir = mkdtempSync(join(tmpdir(), "manifest-stack-local-")); + mkdirSync(join(testDir, "api"), { recursive: true }); + + const result = await buildRuntimeConfig( + { + account: "test.near", + extends: "dev.everything.near/dev.everything.dev", + app: { + host: { + name: "host", + development: "http://localhost:3000", + production: "http://host.cdn", + }, + ui: { name: "ui", development: "http://localhost:3003", production: "http://ui.cdn" }, + api: { name: "api", development: "local:api", production: "http://api.cdn" }, + }, + } as any, + testDir, + "development", + { apiSource: "local" }, + ); + + expect(result.api.source).toBe("local"); + expect(fetchApiPluginManifestMock).not.toHaveBeenCalled(); + expect(result.plugins?.discovered).toBeUndefined(); + expect(result.nodes?.discovered).toBeUndefined(); + + const { rmSync } = await import("node:fs"); + rmSync(testDir, { recursive: true, force: true }); + }); +}); diff --git a/packages/everything-dev/tsdown.config.ts b/packages/everything-dev/tsdown.config.ts index 3071ebf7..0fe93ded 100644 --- a/packages/everything-dev/tsdown.config.ts +++ b/packages/everything-dev/tsdown.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ "src/index.ts", "src/types.ts", "src/config.ts", + "src/dag.ts", "src/fastkv.ts", "src/contract.meta.ts", "src/db.ts", @@ -66,7 +67,9 @@ export default defineConfig({ await writeFile(filepath, SHEBANG + content); } await chmod(filepath, 0o755); - } catch {} + } catch (err) { + console.warn(`[tsdown] Failed to set shebang/permissions on ${file}: ${err}`); + } } }, });