/** * Reads what a deployment tells the clients that are not built alongside it, * from its meta document (`/api/meta`): the experimental-flag posture and the * memory URL. Like `experimental-posture.ts`, it loads none of the runtime * execution graph, so a browser page can import it. */ import { debugStr } from "@commonfabric/data-model"; import { ADOPT_SERVER_FLAGS_ENV, adoptServerExperimentalOptions, type EnvReader, experimentalOptionsFromEnv, parseFlagValue, parseServerExperimentalOptions, SERVER_EXPERIMENTAL_PATH, } from "./experimental-posture.ts"; import type { ExperimentalOptions } from "./runtime.ts"; import { namesApiOrigin, readMemoryUrl } from "./space-host.ts"; /** * The name of the `` element in which a compiled toolshed publishes what * the shell takes from its deployment, to the pages it serves. The `content` * is a {@link DeploymentMetaContent} as JSON. The toolshed writes the element and the * shell reads it, both by this name. */ export const DEPLOYMENT_META_NAME = "cf-deployment"; /** * Where the shell resolves one flag. * * - `"build"`: the shell's build decides, through the build define where * `packages/shell/felt.config.ts` has one and the built-in default * otherwise. CI pins a lane's defines, and the worker checks the * `serverExecution` arm it declares against its own. * - `"deployment"`: the deployment decides. One shell build is served by * every deployment, so a flag whose value differs between deployments * that share a build goes here: a compiled toolshed publishes it, out of * the posture `/api/meta` publishes, in the page it serves * ({@link DEPLOYMENT_META_NAME}), and the shell adopts it from the page, or * from the meta document, under `adoptServerExperimentalOptions`, as `cf` * adopts the whole posture: an explicit build define still wins. Such a * flag has to be `"server"` in `EXPERIMENTAL_FLAG_AUTHORITY`, since that * adoption takes only server-authority flags, and must not change while a * toolshed serves, since the page is built once; the runner's tests hold * both. */ export type ShellFlagSource = "build" | "deployment"; /** * Where the shell resolves every flag, type-gated like * `EXPERIMENTAL_FLAG_AUTHORITY`: a new flag does not compile until it is * placed here, so "does the shell follow the deployment on this?" is a * decision on record. */ export const SHELL_FLAG_SOURCES = { modernCellRep: "build", agentBuiltin: "build", contentAddressedSchemas: "build", commitPreconditions: "build", plainResultReceipts: "build", computedCellIds: "build", lazyMaterialization: "build", serverExecution: "build", localSessionDocuments: "deployment", viewScopedReplication: "build", webViewScopedReplication: "build", readerSchemaPrecedence: "build", localFold: "build", // Whether a connection may carry several spaces is how the deployment // routes connections: a deployment whose router terminates client // connections turns it on, and one that routes each connection by the // space its address names cannot, while both serve one shell build. sharedMemoryConnection: "deployment", // Whether a `const`-bound child is a named instance decides which document // a parent binds it to, so the shell has to run the naming mode of the // deployment whose spaces it opens. instanceNames: "deployment", } as const satisfies Record; /** The flags {@link SHELL_FLAG_SOURCES} gives the deployment. */ export const SHELL_DEPLOYMENT_FLAGS: readonly (keyof ExperimentalOptions)[] = (Object.keys(SHELL_FLAG_SOURCES) as (keyof ExperimentalOptions)[]).filter( (flag) => SHELL_FLAG_SOURCES[flag] === "deployment", ); /** * What a compiled toolshed writes into the page it serves, as the element's * JSON content: the fields of its meta document the shell reads, in the * document's own shape, so that the shell reads a page and a document alike. * `memoryUrl` is the memory URL, or `null` where the deployment has none. * `experimental` is the posture restricted to {@link SHELL_DEPLOYMENT_FLAGS} * ({@link shellFlagsFromDeclared}), or `null` where the toolshed has no * Runtime, as on the meta document. */ export interface DeploymentMetaContent { memoryUrl: string | null; experimental: ExperimentalOptions | null; } /** * The flags the shell adopts from a posture a deployment declared, on its * meta document or in the page it served: `declared` read as * `parseServerExperimentalOptions` reads it, restricted to * {@link SHELL_DEPLOYMENT_FLAGS}. A declaration without one of them says * nothing about it, and the shell's default governs. */ export function shellFlagsFromDeclared(declared: unknown): ExperimentalOptions { const parsed = parseServerExperimentalOptions(declared); const flags: ExperimentalOptions = {}; for (const key of SHELL_DEPLOYMENT_FLAGS) { if (parsed[key] !== undefined) flags[key] = parsed[key]; } return flags; } /** How long one attempt to read the meta document may take. */ const META_ATTEMPT_TIMEOUT_MS = 5_000; /** * The waits before the second and the third attempt to read a meta document * that could not be read for a transient reason: three attempts, 1.25 seconds * of waiting in all. */ const META_RETRY_DELAYS_MS: readonly number[] = [250, 1_000]; /** * The statuses with which a server says that it has no meta document, * which is a deployment that publishes nothing: 404 from a server with no * meta route, 405 from one whose route takes another method, 410 from one * that removed it. */ const NO_DOCUMENT_STATUSES: ReadonlySet = new Set([404, 405, 410]); /** * The transient statuses: the server could not respond now and may in a * moment. A request timeout, a rate limit, and a gateway that could not reach * or wait for a toolshed that is restarting. */ const TRANSIENT_STATUSES: ReadonlySet = new Set([ 408, 429, 502, 503, 504, ]); /** How to read a deployment's meta document. */ export interface DeploymentMetaParams { /** The deployment this client runs against; it serves the document. */ apiUrl: URL; /** * Cancels the read, the waits between attempts included. A caller whose * startup is cancellable must pass its signal. Each attempt also gives up * on its own after five seconds, whether or not a signal is passed, so a * deployment that accepts the connection and then says nothing cannot hold * a caller indefinitely. */ signal?: AbortSignal; /** @internal Seam for tests: the real `fetch` otherwise. */ fetch?: typeof globalThis.fetch; /** @internal Seam for tests: how long one attempt may take. */ attemptTimeoutMs?: number; /** @internal Seam for tests: the waits before the second and later * attempts. Empty makes one attempt. */ retryDelaysMs?: readonly number[]; } /** {@link DeploymentMetaParams} for a client that also adopts the posture. */ export interface DeployedClientParams extends DeploymentMetaParams { /** Reads this process's environment; pass `Deno.env.get` in Deno contexts. */ env: EnvReader; } /** * The outcome of reading a deployment's meta document, or of one attempt to. * * - `conclusive: true`: the server returned its meta document, as `document`, * or said it has none ({@link NO_DOCUMENT_STATUSES}), as an undefined * `document`. Either settles what the deployment publishes. * - `conclusive: false`: the document could not be read. Every other outcome * is this: the server was unreachable or refused the request, an attempt * timed out, it returned another status, or an OK response's body was not a * JSON object. `transient` says whether asking again may succeed: a * connection that failed and a status in {@link TRANSIENT_STATUSES} are * transient. An attempt that timed out is not: a server that accepted the * connection and then sent nothing for five seconds is not asked again, * since the health check that follows would wait on it too. A refusal such * as a 401, a 403 or a 500, a redirect that was not followed, a body that is * not JSON, and an error that is not a network failure, such as a * permission the process lacks, get the same result however often they are * asked. * * `redirectedOffOrigin` is the URL a followed redirect ended at, when that is * not the API URL's deployment ({@link staysOnDeployment}), such as a login or * canonical host. The posture is read from that response as from any other, * as clients did before deployments published a memory URL. The memory URL * is not ({@link memoryUrlFromMeta}). */ type DeploymentMeta = & { redirectedOffOrigin?: string } & ( | { conclusive: true; document: Record | undefined } | { conclusive: false; transient: boolean } ); /** * Reads the deployment's meta document, and returns the last attempt's * outcome. A document that could not be read for a transient reason is asked * for again, up to three attempts in all, since a deployment that is * restarting a toolshed or behind a proxy that is reloading responds again * within moments. An aborted `signal` throws its reason instead of resolving, * whether it arrives before the read, while a request or its body is in * flight, or during a wait between attempts. */ async function readDeploymentMeta( params: DeploymentMetaParams, ): Promise { const delays = params.retryDelaysMs ?? META_RETRY_DELAYS_MS; for (let attempt = 0;; attempt++) { params.signal?.throwIfAborted(); const meta = await readMetaOnce(params); if (meta.conclusive || !meta.transient || attempt >= delays.length) { return meta; } await waitOrAbort(delays[attempt], params.signal); } } /** * Whether a redirect that ended at `url` stayed on `apiUrl`'s deployment: * at its origin, or at the same host on https where the API URL names http, * which is a proxy upgrading the connection to TLS. */ function staysOnDeployment(url: string, apiUrl: URL): boolean { let final: URL; try { final = new URL(url); } catch { return false; } return final.origin === apiUrl.origin || (apiUrl.protocol === "http:" && final.protocol === "https:" && final.host === apiUrl.host); } /** One attempt of {@link readDeploymentMeta}. */ async function readMetaOnce( params: DeploymentMetaParams, ): Promise { const fetchImpl = params.fetch ?? globalThis.fetch; const attempt = new AbortController(); const timer = setTimeout( () => attempt.abort(new DOMException("timed out", "TimeoutError")), params.attemptTimeoutMs ?? META_ATTEMPT_TIMEOUT_MS, ); const signal = params.signal === undefined ? attempt.signal : AbortSignal.any([params.signal, attempt.signal]); try { // The signal rides the request, which is what makes the BODY read below // cancellable too: aborting a signal passed to `fetch` terminates the // ongoing fetch and errors the response's stream, so a stalled // `response.json()` rejects rather than hanging, and lands in the catch. const response = await fetchImpl( new URL(SERVER_EXPERIMENTAL_PATH, params.apiUrl), { signal }, ); // Recorded rather than refused: the posture is read from wherever the // redirect ended, and only the memory URL is not. const offOrigin = response.redirected && !staysOnDeployment(response.url, params.apiUrl) ? { redirectedOffOrigin: response.url } : {}; if (!response.ok) { // Discard the body rather than leaving the connection holding an // unread stream. An error page is not a meta document even when it // parses as one. await response.body?.cancel(); params.signal?.throwIfAborted(); const status = response.status; return NO_DOCUMENT_STATUSES.has(status) ? { ...offOrigin, conclusive: true, document: undefined } : { ...offOrigin, conclusive: false, transient: TRANSIENT_STATUSES.has(status), }; } const body: unknown = await response.json(); params.signal?.throwIfAborted(); return body !== null && typeof body === "object" && !Array.isArray(body) ? { ...offOrigin, conclusive: true, document: body as Record, } : { ...offOrigin, conclusive: false, transient: false }; } catch (error) { // A cancelled startup is the caller's decision, not a server that failed // to respond: propagate it instead of resolving settings for a runtime // construction the caller is abandoning. `fetch` reports a connection // that failed as a `TypeError`, and so does a body whose stream broke // off; a body that is not JSON is a `SyntaxError`. An attempt that // timed out is not asked again ({@link DeploymentMeta}). params.signal?.throwIfAborted(); return { conclusive: false, transient: !attempt.signal.aborted && error instanceof TypeError, }; } finally { clearTimeout(timer); } } /** Waits `ms`, or rejects with `signal`'s reason when it aborts first. */ function waitOrAbort(ms: number, signal: AbortSignal | undefined) { return new Promise((resolve, reject) => { if (signal?.aborted) { reject(signal.reason); return; } const onAbort = () => { clearTimeout(timer); reject(signal!.reason); }; const timer = setTimeout(() => { signal?.removeEventListener("abort", onAbort); resolve(); }, ms); signal?.addEventListener("abort", onAbort, { once: true }); }); } /** * The memory URL a read of the meta document gives: the `memoryUrl` it * publishes, read by `readMemoryUrl`. Each of these is none, and leaves Memory * on `apiUrl`: * * - a document without the field, or with `null`; * - a value naming `apiUrl`'s own origin; * - a published value that is not an HTTP or HTTPS origin, which is dropped * with a warning, as a malformed flag is; * - a server that said it has no document; * - a response a redirect brought from off the API URL's deployment, whatever * it says: a login or canonical host would otherwise decide where Memory * opens, or with a 404 say there is no memory URL. That is warned about, * naming where the redirect ended; * - a document that could not be read. That is warned about, naming the host * Memory stays on, because a deployment with a memory router may not serve * Memory there and the client's Memory would otherwise fail without a word. * Every client opened Memory on `apiUrl` before deployments published a * memory URL, so a deployment without a router keeps working. * * Each read warns at most once. */ function memoryUrlFromMeta( meta: DeploymentMeta, apiUrl: URL, ): URL | undefined { const metaUrl = new URL(SERVER_EXPERIMENTAL_PATH, apiUrl).href; if (meta.redirectedOffOrigin !== undefined) { console.warn( `[deployment-meta] ${metaUrl} redirected to ` + `${meta.redirectedOffOrigin}, which is not this API URL's ` + `deployment; Memory opens on ${apiUrl.origin}. If the deployment is ` + `served from ${originOf(meta.redirectedOffOrigin)}, set the API URL ` + `to that origin.`, ); return undefined; } if (!meta.conclusive) { console.warn( `[deployment-meta] Could not read ${metaUrl}; Memory opens on ` + `${apiUrl.origin}, which may not serve it if the deployment routes ` + `Memory through a memory router.`, ); return undefined; } const declared = meta.document?.memoryUrl; const parsed = readMemoryUrl(declared, apiUrl); if ("refused" in parsed) { console.warn( `[deployment-meta] Ignoring server-published memoryUrl=` + debugStr`$quote${declared} — ${parsed.refused}.`, ); return undefined; } return parsed.memoryUrl; } /** The origin of `url`, or `url` itself where it does not parse. */ function originOf(url: string): string { try { return new URL(url).origin; } catch { return url; } } /** * What the shell takes from its deployment, and whether reading again may * give another: what {@link deploymentForShell} reads from the meta * document, and what a shell takes from a page that states it, which is not * transient. */ export interface DeploymentForShell { /** * The memory URL the deployment publishes, or `undefined`, which leaves * Memory on `apiUrl` ({@link memoryUrlFromMeta}). */ memoryUrl: URL | undefined; /** * The flags the deployment decides for the shell * ({@link shellFlagsFromDeclared}): empty where the document could not be * read or the server said it has none, which adopts nothing, as * {@link settingsForDeployedClient} adopts nothing then. */ experimental: ExperimentalOptions; /** * Whether the document could not be read for a transient reason, a * connection that failed or a status in {@link TRANSIENT_STATUSES}, so that * reading it again may give another result. A caller that keeps the result * keeps it unless this is set: a document the server returned, or said it * has none of, settles what the deployment publishes, and a failure that is * not transient, such as a 401 or an attempt that timed out, would most * likely come out the same way again. */ transient: boolean; } /** * What a deployment publishes for the shell, from one read of its meta * document: the memory URL and the flags it decides for the shell. For a * shell whose page does not state them. The posture is read from wherever a * redirect ended, as {@link settingsForDeployedClient} reads it; the memory * URL is not. */ export async function deploymentForShell( params: DeploymentMetaParams, ): Promise { const meta = await readDeploymentMeta(params); return { memoryUrl: memoryUrlFromMeta(meta, params.apiUrl), experimental: !meta.conclusive || meta.document === undefined ? {} : shellFlagsFromDeclared(meta.document.experimental), transient: !meta.conclusive && meta.transient, }; } /** * Where a deployment this client does NOT run against serves Memory: what a * host hint naming another deployment's origin resolves to * (`Runtime.resolveSpaceHost`). The read is one of {@link readDeploymentMeta} * against that origin, which serves `/api/meta` with CORS `*`, so a browser * page can read it too. * * - `memoryHost`: the memory URL the origin publishes, such as its router's; * or the origin itself where it said it has no document, or returned one * without the field, or one naming its own origin. Every client opened a * space's Memory on the hinted origin before deployments published memory * URLs, so a deployment without a router keeps working. * - `unread`: the memory host could not be learned, and `reason` says why: * the document could not be read, a redirect left the origin's deployment, * or the published value is not an HTTP or HTTPS origin. Each is a case the * client could resolve wrongly if it guessed: the origin may route Memory * through a router that the origin itself does not serve, and the client's * own default is its own deployment's router, which is no place for another * deployment's space. Nothing is warned about here; the caller decides how * often to. */ export type ForeignMemoryHost = | { memoryHost: URL } | { unread: true; reason: string }; /** * Reads where `params.apiUrl`, another deployment's origin (an HTTP or HTTPS * origin, as `normalizeSpaceHost` gives it), serves Memory * ({@link ForeignMemoryHost}). Bounded as {@link settingsForDeployedClient} * describes, and an aborted `signal` throws its reason. */ export async function memoryHostForForeignOrigin( params: DeploymentMetaParams, ): Promise { const origin = params.apiUrl; const meta = await readDeploymentMeta(params); const metaUrl = new URL(SERVER_EXPERIMENTAL_PATH, origin).href; if (meta.redirectedOffOrigin !== undefined) { return { unread: true, reason: `${metaUrl} redirected to ${meta.redirectedOffOrigin}, ` + "which is not that origin's deployment", }; } if (!meta.conclusive) { return { unread: true, reason: `${metaUrl} could not be read` }; } const declared = meta.document?.memoryUrl; const parsed = readMemoryUrl(declared, origin); if ("refused" in parsed) { return { unread: true, reason: `${metaUrl} publishes memoryUrl=` + debugStr`$quote${declared} — ${parsed.refused}`, }; } return { memoryHost: parsed.memoryUrl ?? origin }; } /** What a client that is NOT built alongside its server takes from it. */ export interface DeployedClientSettings { /** The posture ({@link settingsForDeployedClient}). */ experimental: ExperimentalOptions; /** * The host Memory opens on: the memory URL the deployment publishes, such * as a memory router's, or `apiUrl` where it publishes none or its document * could not be read ({@link memoryUrlFromMeta}). Open the storage manager * on it, and pass the same value to the `remoteClient` preset as * `memoryHost`, which gives the runtime its memory URL. One value for both * keeps the runtime's rule for host hints in step with where storage * actually opens Memory. */ memoryHost: URL; } /** * What a client that is NOT built alongside its server takes from it, from * one read of its meta document ({@link readDeploymentMeta}): the posture and * the host Memory opens on. Call it wherever a runtime talks to a deployed * API: `cf`, the pieces controller, the connector hosts, the admin CLIs. The * presets that run against LOCAL emulated storage have no server to ask and * read the environment alone. * * The posture is the deployment's own, with this process's explicit * `EXPERIMENTAL_*` overriding it flag by flag. A document that could not be * read, and a server that said it has none, resolve to the * environment alone: the caller is about to fail loudly on its real work if * the server is genuinely down, and failing here first would only obscure * that. A server that RETURNS a pre-flag document (a meta document * without an `experimental` field, or a posture record silent on * `readerSchemaPrecedence` or `agentBuiltin`) is different: those flags adopt * their legacy declared `false` ({@link parseServerExperimentalOptions}). For * every other flag, absence of a declaration is not a declaration. * {@link ADOPT_SERVER_FLAGS_ENV} keeps the posture on the environment. It does * not stop the read, since the memory URL says where the deployment serves * Memory rather than what a flag is. A response a redirect brought from off * the API URL's deployment gives the posture as before, and no memory URL. * * The read is bounded. Each attempt may take five seconds, and one that does * ends the read. A transient failure is asked again, up to three attempts with * 1.25 seconds of waiting between them. So a server that stalls costs five * seconds, and one that keeps refusing the connection about 1.25. What the * caller does next, such as the runtime's health check, has bounds of its own * or none. * * An aborted `signal` is the one case that does NOT resolve: the caller asked * to stop, so this throws the abort reason rather than handing back settings * nobody is going to use. */ export async function settingsForDeployedClient( params: DeployedClientParams, ): Promise { // Before anything else: a caller that has already stopped gets the abort. params.signal?.throwIfAborted(); const env = experimentalOptionsFromEnv(params.env, "deployment-meta"); const raw = params.env(ADOPT_SERVER_FLAGS_ENV); const refused = raw !== undefined && parseFlagValue(raw, ADOPT_SERVER_FLAGS_ENV, "deployment-meta") === false; const meta = await readDeploymentMeta(params); return { experimental: refused || !meta.conclusive || meta.document === undefined ? env : adoptServerExperimentalOptions( parseServerExperimentalOptions(meta.document.experimental), env, ), memoryHost: memoryUrlFromMeta(meta, params.apiUrl) ?? params.apiUrl, }; } /** * What a client adds to its report of a failed health check when Memory * opens on another host than `apiUrl`. The check does not ask the memory * host, so it says nothing about it either way; the report names it so that * whoever reads it knows Memory is not on the host that failed. Empty when * Memory opens on `apiUrl`. */ export function memoryHostNote(memoryHost: URL, apiUrl: URL): string { return namesApiOrigin(memoryHost, apiUrl) ? "" : ` Memory opens on "${memoryHost.href}", which the health check does not ask.`; }