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