UNPKG

everything-dev

Version:

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

367 lines (365 loc) • 15 kB
const require_services_registry_fastkv = require('../registry/fastkv.cjs'); const require_bos_ref = require('../../utils/bos-ref.cjs'); const require_init_source = require('../resolution/init-source.cjs'); const require_services_federation_service = require('../federation/service.cjs'); const require_ownership = require('../scaffold/ownership.cjs'); let node_fs = require("node:fs"); let node_path = require("node:path"); let node_url = require("node:url"); let effect = require("effect"); let zod = require("zod"); let node_crypto = require("node:crypto"); let every_plugin_shared_deps_spec = require("every-plugin/shared-deps-spec"); let glob = require("glob"); //#region src/services/platform/manifest.ts /** * The platform release manifest (ADR 0034) — one module, the whole release * contract: schema, composer, inventory, registry slot, resolution, and the * compat check. One composer feeds both publish homes (FastKV pointer slot + * in-image staging); the image digest is deliberately absent — it derives from * the tags at deploy preflight. The homes' one known divergence is the compat * floor: publisher-side (previous release) in FastKV, own version — failing * closed — in the image, which cannot read the registry inside the build. */ const PlatformReleaseManifestSchema = zod.z.object({ schemaVersion: zod.z.literal(1), kind: zod.z.literal("everything-dev/platform-manifest"), frameworkVersion: zod.z.string().min(1), everyPluginVersion: zod.z.string().min(1), /** The serving build's repo HEAD (full sha) — omitted when the compose * context has no git (regression image builds, build-arg-less builds). */ gitSha: zod.z.string().min(7).optional(), image: zod.z.object({ ref: zod.z.string().min(1), tags: zod.z.array(zod.z.string().min(1)) }), catalog: zod.z.record(zod.z.string(), zod.z.string()), sharedDeps: zod.z.record(zod.z.string(), zod.z.object({ version: zod.z.string(), requiredVersion: zod.z.union([zod.z.string(), zod.z.boolean()]), singleton: zod.z.boolean(), eager: zod.z.boolean(), strictVersion: zod.z.boolean(), shareScope: zod.z.string() })), templateInventory: zod.z.array(zod.z.object({ path: zod.z.string(), sha256: zod.z.string().regex(/^[0-9a-f]{64}$/) })), compat: zod.z.object({ minChild: zod.z.string().min(1) }) }); function getPlatformManifestKey(accountId, gatewayId) { return `apps/${accountId}/${gatewayId}/platform.json`; } /** * The template-source file set a release inventory hashes: the ownership * inventory's static paths, the agent-sync globs, and the per-plugin template * scaffolding (`plugins/_template/*` — the sources of the per-plugin sync * patterns). Files absent from the release source are skipped, mirroring * sync's transitional tolerance. */ const TEMPLATE_SCAFFOLD_SOURCES = [ "plugins/_template/rspack.config.js", "plugins/_template/tsconfig.json", "plugins/_template/tests/types.d.ts" ]; function templateSourcePaths(sourceDir) { const paths = /* @__PURE__ */ new Set([...require_ownership.FRAMEWORK_OWNED_SYNC_FILES, ...TEMPLATE_SCAFFOLD_SOURCES]); for (const match of glob.glob.sync(require_ownership.AGENT_SYNC_GLOBS, { cwd: sourceDir, nodir: true, dot: true, ignore: [ "**/node_modules/**", "**/.git/**", "**/dist/**", "**/.bos/**" ] })) paths.add((0, node_path.relative)(sourceDir, (0, node_path.join)(sourceDir, match))); return [...paths].sort(); } function computeTemplateInventory(sourceDir) { const inventory = []; for (const relativeFilePath of templateSourcePaths(sourceDir)) { const fullPath = (0, node_path.join)(sourceDir, relativeFilePath); if (!(0, node_fs.existsSync)(fullPath)) continue; try { const content = (0, node_fs.readFileSync)(fullPath); inventory.push({ path: relativeFilePath, sha256: (0, node_crypto.createHash)("sha256").update(content).digest("hex") }); } catch {} } return inventory.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0); } function composeSharedDepsFromCatalog(catalog) { const deps = {}; for (const spec of every_plugin_shared_deps_spec.SHARED_DEP_SPECS) { const version = catalog[spec.name]; if (!version) continue; deps[spec.name] = { version, ...(0, every_plugin_shared_deps_spec.shareConfigFor)(spec, version) }; } return deps; } function composePlatformReleaseManifest(inputs) { const semver = parseSemver(inputs.frameworkVersion.trim()); const tags = semver ? [`v${semver.major}.${semver.minor}.${semver.patch}${semver.prerelease ? `-${semver.prerelease}` : ""}`] : []; return PlatformReleaseManifestSchema.parse({ schemaVersion: 1, kind: "everything-dev/platform-manifest", frameworkVersion: inputs.frameworkVersion, everyPluginVersion: inputs.everyPluginVersion, ...inputs.gitSha ? { gitSha: inputs.gitSha } : {}, image: { ref: inputs.imageRef, tags }, catalog: inputs.catalog, sharedDeps: composeSharedDepsFromCatalog(inputs.catalog), templateInventory: inputs.templateInventory, compat: { minChild: inputs.compatMinChild } }); } /** The one serializer for both publish homes — byte-identical by construction. */ function serializePlatformReleaseManifest(manifest) { return JSON.stringify(manifest); } /** * The in-image copy (ADR 0034 pt 1): generated inside the docker build at * `packages/everything-dev/dist/platform-manifest.json`. Resolved relative to * this module — the compiled layout is `<pkg>/dist/services/platform/manifest.mjs` * (two directory levels up = `<pkg>/dist/`), the source layout is * `<pkg>/src/services/platform/manifest.ts` (four levels up = the pnpm parent * dir, then `<pkg>/dist/`). Both candidates are tried; absent or unparsable → * null. */ function readInImagePlatformManifest() { const moduleFile = (0, node_url.fileURLToPath)(require("url").pathToFileURL(__filename).href); const candidates = [(0, node_path.join)(moduleFile, "..", "..", "platform-manifest.json"), (0, node_path.join)(moduleFile, "..", "..", "..", "..", "dist", "platform-manifest.json")]; for (const path of candidates) try { return PlatformReleaseManifestSchema.parse(JSON.parse((0, node_fs.readFileSync)(path, "utf-8"))); } catch {} return null; } function parseSemver(version) { const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(version.trim()); if (!match) return null; return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]), ...match[4] ? { prerelease: match[4] } : {} }; } function compareSemver(a, b) { const left = parseSemver(a); const right = parseSemver(b); if (!left || !right) return a === b ? 0 : a < b ? -1 : 1; if (left.major !== right.major) return left.major - right.major; if (left.minor !== right.minor) return left.minor - right.minor; return left.patch - right.patch; } /** * The additive compat gate (ADR 0034 pt 5): the child's framework version must * sit inside the release's compat window, and strict singletons must agree * exactly with the release's pins. A sibling of the federation check — never * a relaxation of it. */ function checkPlatformCompat(manifest, subject) { const violations = []; if (subject.platformVersion) { if (compareSemver(subject.platformVersion, manifest.compat.minChild) < 0) violations.push({ kind: "minChild", message: `platform ${subject.platformVersion} is older than this release's compat floor ${manifest.compat.minChild}` }); if (compareSemver(subject.platformVersion, manifest.frameworkVersion) > 0) violations.push({ kind: "minChild", message: `platform ${subject.platformVersion} is newer than this release ${manifest.frameworkVersion}` }); } for (const [name, pin] of Object.entries(manifest.sharedDeps)) { const childVersion = subject.catalog[name]; if (pin.strictVersion) { if (!childVersion) { violations.push({ kind: "shared-dep", name, message: `strict singleton "${name}" is missing from the child catalog (release pins ${pin.version})` }); continue; } if (childVersion !== pin.version) violations.push({ kind: "shared-dep", name, message: `strict singleton "${name}" is ${childVersion}, the release pins ${pin.version}` }); continue; } if (typeof pin.requiredVersion === "string" && childVersion) { if (!require_services_federation_service.satisfiesConstraint(childVersion, pin.requiredVersion)) violations.push({ kind: "shared-dep", name, message: `"${name}" is ${childVersion}, which does not satisfy the release range ${pin.requiredVersion}` }); } } return violations; } /** * How long a cached resolution may stand in for a live fetch. The cache * exists for offline dev; without a freshness bound a registry outage (or a * stale cache written before the parent's latest publish) silently serves a * months-old release manifest — pins derive from it and the mismatch can * take an hour to diagnose. Past the TTL the cache is refused: pin-less * resolution degrades to "no manifest", pinned resolution fails loud with * the pin-not-found error. */ const PLATFORM_MANIFEST_CACHE_TTL_MS = 864e5; const CACHE_DIR = ".bos"; const CACHE_FILE = "platform-manifest.json"; /** * A cached resolution stands in for a live fetch only while it is fresh: * `resolvedAt` inside the TTL, and parseable (a cache with an unparseable * timestamp is treated as stale — never trusted). */ function isFresh(cached, opts) { const now = (opts.now ?? Date.now)(); const ttl = opts.cacheTtlMs ?? 864e5; const resolvedAt = Date.parse(cached.resolvedAt); if (Number.isNaN(resolvedAt)) return false; return now - resolvedAt <= ttl; } function defaultCachePath(configDir) { return (0, node_path.join)(configDir, CACHE_DIR, CACHE_FILE); } function defaultCacheRead(configDir) { try { const raw = JSON.parse((0, node_fs.readFileSync)(defaultCachePath(configDir), "utf-8")); return raw?.kind === "everything-dev/platform-manifest-cache" ? raw : null; } catch { return null; } } function defaultCacheWrite(configDir, cached) { try { (0, node_fs.mkdirSync)((0, node_path.join)(configDir, CACHE_DIR), { recursive: true }); (0, node_fs.writeFileSync)(defaultCachePath(configDir), `${JSON.stringify(cached, null, 2)}\n`); } catch {} } function defaultFetchManifest(account, gateway) { return (async () => { const raw = await require_services_registry_fastkv.fetchRegistryKeyOrNull(account, gateway, "platform.json"); if (!raw) return null; return PlatformReleaseManifestSchema.parse(raw); })(); } function defaultFetchParent(account, gateway) { return require_init_source.fetchParentConfig(account, gateway).then((config) => config).catch(() => null); } /** * The ONE extends-chain walk (child → root via FastKV parent configs): both * `resolvePlatformManifest` and the upgrade source resolution used to carry * their own `fetchParentConfig` loops; this is the shared implementation. * A fetch failure (throw or null) stops the walk — the chain gathered so far * is still returned. */ async function walkExtendsChain(input) { const fetchParent = input.fetchParent ?? defaultFetchParent; const chain = []; let repository = input.localRepository; const startsRef = require_bos_ref.getExtendsRef(input.localConfig); let cursor = typeof startsRef === "string" && startsRef.startsWith("bos://") ? require_bos_ref.parseBosRef(startsRef) ?? null : null; const visited = /* @__PURE__ */ new Set(); let circularAt; while (cursor) { const ref = `bos://${cursor.account}/${cursor.gateway}`; if (visited.has(ref)) { circularAt = ref; break; } visited.add(ref); chain.push(cursor); const parentConfig = await fetchParent(cursor.account, cursor.gateway); if (!parentConfig) break; if (typeof parentConfig.repository === "string" && parentConfig.repository) repository = parentConfig.repository; const nextRef = require_bos_ref.getExtendsRef(parentConfig); cursor = typeof nextRef === "string" && nextRef.startsWith("bos://") ? require_bos_ref.parseBosRef(nextRef) ?? null : null; } return { chain, ...repository !== void 0 ? { repository } : {}, ...circularAt ? { circularAt } : {} }; } async function resolvePlatformManifest(opts) { const fetchManifest = opts.fetchManifest ?? ((a, g) => defaultFetchManifest(a, g)); const cacheRead = opts.cacheRead ?? (() => defaultCacheRead(opts.configDir)); const cacheWrite = opts.cacheWrite ?? ((cached) => defaultCacheWrite(opts.configDir, cached)); const { chain } = await walkExtendsChain({ localConfig: opts.localConfig, ...opts.fetchParent ? { fetchParent: opts.fetchParent } : {} }); const writeCache = (manifest, source) => { const cached = { schemaVersion: 1, kind: "everything-dev/platform-manifest-cache", resolvedAt: (/* @__PURE__ */ new Date()).toISOString(), source, manifest }; cacheWrite(cached); return { manifest, source, fromCache: false }; }; const pin = typeof opts.localConfig.platform === "string" ? opts.localConfig.platform : void 0; for (const node of chain) { const manifest = await fetchManifest(node.account, node.gateway); if (!manifest) continue; if (!pin) return writeCache(manifest, node); if (manifest.frameworkVersion === pin) return writeCache(manifest, node); } const cached = cacheRead(); if (cached && (!pin || cached.manifest.frameworkVersion === pin) && isFresh(cached, opts)) return { manifest: cached.manifest, source: cached.source, fromCache: true }; if (pin) throw new Error(`platform pin ${pin} not found in the extends chain${chain.length > 0 ? ` (${chain.map((n) => `bos://${n.account}/${n.gateway}`).join(" → ")})` : ""} — publish the release or clear the pin`); return null; } /** * The platform release manifest as a Context.Service: the same "module is * the implementation, the tag is the seam" shape as FastKVRegistryService — * CLI handlers `yield*` the tag; pure and standalone callers keep using the * plain functions. Tests provide `Layer.succeed` fakes instead of module * mocks. */ var PlatformManifestService = class extends effect.Context.Service()("everything-dev/platform/manifest") { static Live = effect.Layer.succeed(this, this.of({ resolvePlatformManifest, composePlatformReleaseManifest, checkPlatformCompat, readInImagePlatformManifest })); }; //#endregion exports.PLATFORM_MANIFEST_CACHE_TTL_MS = PLATFORM_MANIFEST_CACHE_TTL_MS; exports.PlatformManifestService = PlatformManifestService; exports.PlatformReleaseManifestSchema = PlatformReleaseManifestSchema; exports.checkPlatformCompat = checkPlatformCompat; exports.composePlatformReleaseManifest = composePlatformReleaseManifest; exports.composeSharedDepsFromCatalog = composeSharedDepsFromCatalog; exports.computeTemplateInventory = computeTemplateInventory; exports.getPlatformManifestKey = getPlatformManifestKey; exports.readInImagePlatformManifest = readInImagePlatformManifest; exports.resolvePlatformManifest = resolvePlatformManifest; exports.serializePlatformReleaseManifest = serializePlatformReleaseManifest; exports.walkExtendsChain = walkExtendsChain; //# sourceMappingURL=manifest.cjs.map