everything-dev
Version:
A consolidated product package for building Module Federation apps with oRPC APIs.
401 lines (399 loc) • 16.2 kB
JavaScript
import { fetchRegistryKey, fetchRegistryKeyOrNull } from "../registry/fastkv.mjs";
import { getExtendsRef, parseBosRef } from "../../utils/bos-ref.mjs";
import { fetchParentConfig } from "../resolution/init-source.mjs";
import { compareSemver, satisfiesConstraint } from "../../utils/semver.mjs";
import { AGENT_SYNC_GLOBS, FRAMEWORK_OWNED_SYNC_FILES } from "../scaffold/ownership.mjs";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join, relative } from "node:path";
import { fileURLToPath } from "node:url";
import { Context, Layer } from "effect";
import { z } from "zod";
import { createHash } from "node:crypto";
import { SHARED_DEP_SPECS, shareConfigFor } from "every-plugin/shared-deps-spec";
import { glob } from "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 = z.object({
schemaVersion: z.literal(1),
kind: z.literal("everything-dev/platform-manifest"),
frameworkVersion: z.string().min(1),
everyPluginVersion: 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: z.string().min(7).optional(),
image: z.object({
ref: z.string().min(1),
tags: z.array(z.string().min(1))
}),
catalog: z.record(z.string(), z.string()),
sharedDeps: z.record(z.string(), z.object({
version: z.string(),
requiredVersion: z.union([z.string(), z.boolean()]),
singleton: z.boolean(),
eager: z.boolean(),
strictVersion: z.boolean(),
shareScope: z.string()
})),
templateInventory: z.array(z.object({
path: z.string(),
sha256: z.string().regex(/^[0-9a-f]{64}$/)
})),
compat: z.object({ minChild: z.string().min(1) }),
/**
* The release's own slot-pin map (ADR 0034 amendment — release-scoped
* extends inheritance): each entry records the immutable version-manifest
* pointer the release published for a config slot (dotted config path, e.g.
* `app.auth`, `app.auth.ui`, `plugins.votes.ui`) — the son of the store, so
* children pinned to this release can re-point inherited slots at THESE
* bytes instead of the parent's live config whenever the parent publishes.
* Optional: releases composed before the field carry no slots, and old
* readers strip unknown keys.
*/
slots: z.record(z.string(), z.object({
base: z.string().min(1),
pin: z.object({
manifest: z.string().min(1),
integrity: z.string().min(1)
})
})).optional()
});
function getPlatformManifestKey(accountId, gatewayId) {
return `apps/${accountId}/${gatewayId}/platform.json`;
}
/** The immutable per-release slot (#573): one key per released framework
* version, written once, never mutated — a divergent republish is a burned
* version and fails loud. `platform.json` remains the moving pointer; slot-
* first resolution (#574) reads slots directly. */
function getPlatformVersionSlotKey(accountId, gatewayId, version) {
return `apps/${accountId}/${gatewayId}/platform-versions/${version}.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([...FRAMEWORK_OWNED_SYNC_FILES, ...TEMPLATE_SCAFFOLD_SOURCES]);
for (const match of glob.sync(AGENT_SYNC_GLOBS, {
cwd: sourceDir,
nodir: true,
dot: true,
ignore: [
"**/node_modules/**",
"**/.git/**",
"**/dist/**",
"**/.bos/**"
]
})) paths.add(relative(sourceDir, join(sourceDir, match)));
return [...paths].sort();
}
function computeTemplateInventory(sourceDir) {
const inventory = [];
for (const relativeFilePath of templateSourcePaths(sourceDir)) {
const fullPath = join(sourceDir, relativeFilePath);
if (!existsSync(fullPath)) continue;
try {
const content = readFileSync(fullPath);
inventory.push({
path: relativeFilePath,
sha256: 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 SHARED_DEP_SPECS) {
const version = catalog[spec.name];
if (!version) continue;
deps[spec.name] = {
version,
...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}` : ""}`] : [];
const slots = inputs.slots ? Object.fromEntries(Object.entries(inputs.slots).sort(([a], [b]) => a < b ? -1 : 1)) : void 0;
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 },
...slots ? { slots } : {}
});
}
/** 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 = fileURLToPath(import.meta.url);
const candidates = [join(moduleFile, "..", "..", "platform-manifest.json"), join(moduleFile, "..", "..", "..", "..", "dist", "platform-manifest.json")];
for (const path of candidates) try {
return PlatformReleaseManifestSchema.parse(JSON.parse(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] } : {}
};
}
/**
* 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 (!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 ONLY (#574) — never stale-pin grace: slot-first
* resolution makes old pins resolvable forever from immutable slots, so a
* cache can no longer be the reason a pin resolves. Without a freshness
* bound a registry outage (or a 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 slot key named.
*/
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 join(configDir, CACHE_DIR, CACHE_FILE);
}
function defaultCacheRead(configDir) {
try {
const raw = JSON.parse(readFileSync(defaultCachePath(configDir), "utf-8"));
return raw?.kind === "everything-dev/platform-manifest-cache" ? raw : null;
} catch {
return null;
}
}
function defaultCacheWrite(configDir, cached) {
try {
mkdirSync(join(configDir, CACHE_DIR), { recursive: true });
writeFileSync(defaultCachePath(configDir), `${JSON.stringify(cached, null, 2)}\n`);
} catch {}
}
function defaultFetchManifest(account, gateway) {
return (async () => {
const raw = await fetchRegistryKeyOrNull(account, gateway, "platform.json");
if (!raw) return null;
return PlatformReleaseManifestSchema.parse(raw);
})();
}
function defaultFetchSlot(account, gateway, version) {
return fetchRegistryKey(account, gateway, `platform-versions/${version}.json`);
}
function defaultFetchParent(account, gateway) {
return 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 = getExtendsRef(input.localConfig);
let cursor = typeof startsRef === "string" && startsRef.startsWith("bos://") ? 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 = getExtendsRef(parentConfig);
cursor = typeof nextRef === "string" && nextRef.startsWith("bos://") ? 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 fetchSlot = opts.fetchSlot ?? ((a, g, v) => defaultFetchSlot(a, g, v));
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, provenance) => {
const cached = {
schemaVersion: 1,
kind: "everything-dev/platform-manifest-cache",
resolvedAt: (/* @__PURE__ */ new Date()).toISOString(),
source,
manifest,
provenance
};
cacheWrite(cached);
return {
manifest,
source,
fromCache: false,
provenance
};
};
const pin = typeof opts.localConfig.platform === "string" ? opts.localConfig.platform : void 0;
if (pin) {
for (const node of chain) {
const slotKey = getPlatformVersionSlotKey(node.account, node.gateway, pin);
let raw;
try {
raw = await fetchSlot(node.account, node.gateway, pin);
} catch (error) {
const cached = cacheRead();
if (cached && cached.manifest.frameworkVersion === pin && isFresh(cached, opts)) return {
manifest: cached.manifest,
source: cached.source,
fromCache: true,
provenance: cached.provenance ?? "slot"
};
throw new Error(`platform pin ${pin}: the version slot read failed (${slotKey}): ${error instanceof Error ? error.message : String(error)}`);
}
if (raw) return writeCache(PlatformReleaseManifestSchema.parse(raw), node, "slot");
}
throw new Error(`platform pin ${pin}: no version slot in the extends chain${chain.length > 0 ? ` (${chain.map((n) => `bos://${n.account}/${n.gateway}`).join(" → ")})` : ""} — the release's manifest leg never published it; re-run the release deploy (or \`bos upgrade\` to re-pin)`);
}
for (const node of chain) {
const manifest = await fetchManifest(node.account, node.gateway);
if (!manifest) continue;
return writeCache(manifest, node, "pointer");
}
const cached = cacheRead();
if (cached && isFresh(cached, opts)) return {
manifest: cached.manifest,
source: cached.source,
fromCache: true,
provenance: cached.provenance ?? "pointer"
};
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 Context.Service()("everything-dev/platform/manifest") {
static Live = Layer.succeed(this, this.of({
resolvePlatformManifest,
composePlatformReleaseManifest,
checkPlatformCompat,
readInImagePlatformManifest
}));
};
//#endregion
export { PLATFORM_MANIFEST_CACHE_TTL_MS, PlatformManifestService, PlatformReleaseManifestSchema, checkPlatformCompat, composePlatformReleaseManifest, composeSharedDepsFromCatalog, computeTemplateInventory, getPlatformManifestKey, getPlatformVersionSlotKey, readInImagePlatformManifest, resolvePlatformManifest, serializePlatformReleaseManifest, walkExtendsChain };
//# sourceMappingURL=manifest.mjs.map