everything-dev
Version:
A consolidated product package for building Module Federation apps with oRPC APIs.
295 lines (293 loc) • 13.3 kB
JavaScript
import { FetchNetworkError, HttpFetch } from "../../utils/http-client.mjs";
import { isAppDescriptorPath } from "./load.mjs";
import { bundleCachePath, readBundleCache, writeBundleCache } from "../registry/bundle-cache.mjs";
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { Cause, Context, Data, Duration, Effect, Layer, ManagedRuntime, Schedule, Schema } from "effect";
import { cacheControlOf } from "every-plugin/build/artifact-names";
import { readFile } from "node:fs/promises";
//#region src/services/resolution/resolver.ts
var BundleReadError = class extends Schema.TaggedError()("BundleReadError", {
path: Schema.String,
cause: Schema.optional(Schema.Unknown)
}) {};
const MIME_TYPES = {
".js": "text/javascript",
".mjs": "text/javascript",
".cjs": "text/javascript",
".css": "text/css",
".json": "application/json",
".map": "application/json",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".avif": "image/avif",
".ico": "image/x-icon",
".html": "text/html",
".htm": "text/html",
".webmanifest": "application/manifest+json",
".md": "text/markdown",
".ts": "text/plain",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
".otf": "font/otf",
".eot": "application/vnd.ms-opentype",
".wasm": "application/wasm",
".txt": "text/xml",
".xml": "application/xml"
};
/**
* Maps a bundle URL onto the staged namespace layout. Bundle URLs carry their
* namespace in the path (`…/bundles/<account>/<gateway>/<workspace>/…`); the
* staged layout mirrors it under `<bundleDir>/<account>/<gateway>/…`. The
* namespace is taken from the URL itself — this answers "which staged
* directory do these bytes live in?" rather than "is this my namespace?" —
* and the result is contained within the account/gateway directory. Returns
* null for foreign shapes.
*/
function bundleUrlToStagedPath(url, bundleDir) {
let parsed;
try {
parsed = new URL(url);
} catch {
return null;
}
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null;
const segments = parsed.pathname.split("/").filter(Boolean);
if (segments[0] !== "bundles" || segments.length < 3) return null;
let decoded;
try {
decoded = segments.slice(1).map((segment) => decodeURIComponent(segment));
} catch {
return null;
}
const [account, gateway, ...rest] = decoded;
if (!account || !gateway || rest.length === 0) return null;
const nsDir = path.resolve(bundleDir, account, gateway);
const filePath = path.resolve(nsDir, ...rest);
if (filePath === nsDir || !filePath.startsWith(nsDir + path.sep)) return null;
return filePath;
}
/** Resolve a relative config-supplied path inside `dir`, or null on escape. */
function resolveContained(dir, relative) {
const resolved = path.resolve(dir, ...relative.split("/").filter(Boolean));
if (resolved === dir || !resolved.startsWith(dir + path.sep)) return null;
return resolved;
}
/**
* Own-namespace path for a bundle URL: the shared URL→staged-path mapping,
* narrowed to the runtime identity — a URL whose encoded namespace differs
* from `namespace.account`/`namespace.gateway` must fall to the network,
* never serve a sibling namespace's staged bytes.
*/
function bundleUrlToLocalPath(url, namespace) {
const filePath = bundleUrlToStagedPath(url, namespace.bundleDir);
if (!filePath) return null;
const nsDir = path.resolve(namespace.bundleDir, namespace.account, namespace.gateway);
if (filePath !== nsDir && !filePath.startsWith(nsDir + path.sep)) return null;
return filePath;
}
var BundleResolver = class BundleResolver extends Context.Service()("everything-dev/resolver/BundleResolver") {
static layer(namespace) {
if (!namespace) return Layer.succeed(BundleResolver, BundleResolver.of({ lookup: () => Effect.succeed(null) }));
const respond = Effect.fn("BundleResolver.respond")(function* (url) {
const filePath = bundleUrlToLocalPath(url, namespace);
if (!filePath) return null;
const bytes = yield* Effect.tryPromise({
try: () => readFile(filePath),
catch: (cause) => new BundleReadError({
path: filePath,
cause
})
}).pipe(Effect.catchTag("BundleReadError", () => Effect.succeed(null)));
if (bytes === null) return null;
const name = path.basename(filePath);
const contentType = MIME_TYPES[path.extname(name).toLowerCase()] ?? "application/octet-stream";
return new Response(new Uint8Array(bytes), { headers: {
"content-type": contentType,
"cache-control": cacheControlOf(name),
etag: `"${Buffer.from(bytes.subarray(0, 4096)).toString("base64").slice(0, 24)}"`
} });
});
return Layer.effect(BundleResolver, Effect.succeed(BundleResolver.of({ lookup: (input) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
return respond(url);
} })));
}
};
const staleResponse = (bytes, url) => {
const name = url.split("?")[0]?.split("/").pop() ?? "";
const contentType = MIME_TYPES[path.extname(name).toLowerCase()] ?? "application/octet-stream";
return new Response(bytes.slice().buffer, {
status: 200,
headers: {
"content-type": contentType,
"x-bundle-cache": "stale"
}
});
};
const DEFAULT_ATTEMPT_TIMEOUT = "30 seconds";
const RETRY_BASE_DELAY = "250 millis";
const RETRY_CAP = "5 seconds";
const RETRY_TIMES = 4;
const isTransientStatus = (status) => status === 429 || status >= 500;
function requestMethod(input, init) {
if (init?.method) return init.method.toUpperCase();
if (typeof input === "string" || input instanceof URL) return "GET";
return input.method.toUpperCase();
}
const asError = (cause) => cause instanceof Error ? cause : new Error(String(cause));
/** A 429/5xx response — carried as a failure so the Schedule can retry it,
* with the Response kept for the exhausted-retries fallthrough. */
var TransientBundleResponse = class extends Data.TaggedError("TransientBundleResponse") {};
/**
* One network attempt through the `HttpFetch` policy: the fetch runs under
* the fiber's interruption signal, and `Effect.timeout` interrupts at the
* deadline — aborting the in-flight request. The response is returned
* regardless of status; transient statuses are lifted by the caller.
*/
const fetchAttempt = Effect.fn("resolver.attempt")(function* (http, input, init, timeout) {
return yield* http.attempt(input, init).pipe(Effect.timeoutOrElse({
duration: Duration.toMillis(timeout),
orElse: () => Effect.fail(new Cause.TimeoutError())
}), Effect.filterOrFail((response) => !isTransientStatus(response.status), (response) => new TransientBundleResponse({ response })));
});
/**
* The resilience policy for idempotent GETs crossing the interceptor
* (ADR 0021 amendment — the CDN-only image): a cold boot has no staged
* fallback, so one edge hiccup or a hung connection during the load window
* must not hard-fail the host or a plugin. Transient statuses and network
* errors (including the deadline's TimeoutError) retry on a capped
* exponential schedule before propagating; the last transient response is
* returned so an explicit stale-if-error cache still sees it. Non-GETs pass
* through untouched; a caller-initiated cancellation is never spent on the
* schedule.
*/
const resilientFetch = Effect.fn("resolver.fetch")(function* (http, input, init, timeout) {
if (requestMethod(input, init) !== "GET") return yield* http.attempt(input, init).pipe(Effect.mapError((cause) => asError(cause.cause)));
return yield* fetchAttempt(http, input, init, timeout).pipe(Effect.retry({
schedule: Schedule.exponential(RETRY_BASE_DELAY).pipe(Schedule.upTo({
duration: RETRY_CAP,
times: RETRY_TIMES
})),
while: (error) => !((error instanceof FetchNetworkError || error instanceof Cause.TimeoutError) && init?.signal?.aborted)
}), Effect.catchTags({
TransientBundleResponse: (error) => Effect.succeed(error.response),
FetchNetworkError: (error) => Effect.fail(asError(error.cause))
}));
});
/**
* Installs the resolver as a global fetch interceptor — the one seam every
* boot-time consumer shares (config manifest discovery, contract-type
* fetches, orchestrator host loading, MF remoteEntry/identity probes) and
* the SSR serving path. One layered resolution policy, as a single Effect
* program on the managed runtime: staged disk → network → (explicitly
* opted-in) stale-if-error. Own-namespace URLs resolve from the staged
* directory and fall through to the network on a miss; a miss never touches
* the write-through cache — for the own namespace the staged disk IS the
* resilience artifact. Inert when neither a staged namespace nor an
* explicit cache is configured.
*/
function installGlobalBundleFetch(options) {
const namespace = options.namespace;
const cacheDir = options.cacheDir;
const timeout = Duration.millis(options.timeoutMs ?? Duration.toMillis(DEFAULT_ATTEMPT_TIMEOUT));
const original = globalThis.fetch;
const runtime = ManagedRuntime.make(Layer.mergeAll(BundleResolver.layer(namespace), HttpFetch.layerWith({ fetchImpl: original })));
const program = Effect.fn("resolver.program")(function* (input, init) {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
const http = yield* HttpFetch;
if (namespace && bundleUrlToLocalPath(url, namespace)) {
const local = yield* (yield* BundleResolver).lookup(input);
if (local !== null) return local;
return yield* resilientFetch(http, input, init, timeout);
}
if (cacheDir !== void 0 && bundleCachePath(url, { cacheDir })) {
const cachedRead = Effect.tryPromise(() => readBundleCache(url, { cacheDir }));
return yield* Effect.gen(function* () {
const response = yield* resilientFetch(http, input, init, timeout);
if (response.ok) yield* Effect.tryPromise(async () => {
const bytes = new Uint8Array(await response.clone().arrayBuffer());
await writeBundleCache(url, bytes, { cacheDir });
}).pipe(Effect.ignore);
else {
const cached = yield* cachedRead;
if (cached) return staleResponse(cached, url);
}
return response;
}).pipe(Effect.catch((error) => Effect.gen(function* () {
const cached = yield* cachedRead;
if (cached) return staleResponse(cached, url);
return yield* Effect.fail(error);
})));
}
return yield* resilientFetch(http, input, init, timeout);
});
const patched = ((input, init) => runtime.runPromise(program(input, init)));
const originalPreconnect = original.preconnect;
if (originalPreconnect) patched.preconnect = originalPreconnect.bind(original);
globalThis.fetch = patched;
return { uninstall: async () => {
if (globalThis.fetch === patched) globalThis.fetch = original;
await runtime.dispose();
} };
}
/**
* A deployment env that accidentally carries an empty or quoted-empty
* `BOS_BUNDLE_DIR` (e.g. the literal two characters `""` from a quoting
* mistake) normalizes to unset — silently, no warning.
*/
const normalizeBundleDir = (raw) => {
const trimmed = raw?.trim();
if (!trimmed) return void 0;
const unquoted = trimmed.length >= 2 && (trimmed.startsWith("\"") && trimmed.endsWith("\"") || trimmed.startsWith("'") && trimmed.endsWith("'")) ? trimmed.slice(1, -1).trim() : trimmed;
return unquoted.length > 0 ? unquoted : void 0;
};
/**
* Boot-time installer for the CLI and the host server: derives the own
* namespace from `BOS_BUNDLE_DIR` plus the runtime identity
* (`BOS_ACCOUNT`/`BOS_GATEWAY` registry env taking precedence over the
* bos.config.json fields), and the stale-if-error cache from an explicit
* `BOS_BUNDLE_CACHE_DIR` — never implicit. Tier auto-detection (ADR 0021):
* an identity whose namespace is not actually staged under the bundle dir
* drops to the registry tier — plain resilient network fetch — instead of
* deterministically 404ing own-namespace URLs against a foreign image's
* staged bytes. Never throws — a missing or malformed identity leaves the
* default fetch in place.
*/
function installBundleFetchFromEnv(input) {
const bundleDir = normalizeBundleDir(process.env.BOS_BUNDLE_DIR);
const cacheDir = process.env.BOS_BUNDLE_CACHE_DIR;
if (!bundleDir && cacheDir === void 0) return null;
let configAccount;
let configGateway;
const configPath = input.configPath;
if (configPath && !isAppDescriptorPath(configPath)) try {
const parsed = JSON.parse(readFileSync(configPath, "utf8"));
configAccount = parsed.account;
configGateway = parsed.domain;
} catch {}
const account = process.env.BOS_ACCOUNT ?? configAccount;
const gateway = process.env.BOS_GATEWAY ?? configGateway;
if (bundleDir && (!account || !gateway)) return null;
let namespace;
if (bundleDir && account && gateway) {
if (existsSync(path.join(bundleDir, account, gateway))) namespace = {
bundleDir,
account,
gateway
};
else console.warn(`[bundles] staged namespace ${account}/${gateway} not found under ${JSON.stringify(bundleDir)} — registry tier (network fetch)`);
}
return installGlobalBundleFetch({
namespace,
cacheDir
});
}
//#endregion
export { BundleReadError, BundleResolver, bundleUrlToLocalPath, bundleUrlToStagedPath, installBundleFetchFromEnv, installGlobalBundleFetch, resolveContained };
//# sourceMappingURL=resolver.mjs.map