feat: prepare 2.6.1-rc5 shared databases and build SSE

This commit is contained in:
2026-10-05 11:09:52 +00:00 Unverified
parent f76165603e
commit 828bf3a328
25 changed files with 2557 additions and 196 deletions
+110 -6
View File
@@ -1,6 +1,8 @@
import { KUBER_API_BASE_URL } from "../const";
import type { ApiProblemDetails } from "../shared/api";
import { KUBER_VERSION_HEADER } from "../shared/version";
import { readSession, type KuberSession } from "./session";
import { observeServerVersion } from "./version-update";
export type { ApiProblemDetails } from "../shared/api";
@@ -19,6 +21,8 @@ export type ApiRequestOptions = {
timeoutMs?: number;
/** Byte offset for resumable binary upload requests. */
uploadOffset?: number;
/** Overrides version observation for embedded clients and tests. */
onServerVersion?: (version: string | null) => void;
};
export type ApiUploadOptions = ApiRequestOptions & {
@@ -181,12 +185,23 @@ async function sendRequest(
const headers = await requestHeaders(init, options);
const { json, ...requestInit } = init;
const body = Object.hasOwn(init, "json") ? JSON.stringify(json) : init.body;
return fetch(`${options.baseUrl ?? KUBER_API_BASE_URL}${path}`, {
...requestInit,
body,
headers,
signal,
});
const response = await fetch(
`${options.baseUrl ?? KUBER_API_BASE_URL}${path}`,
{
...requestInit,
body,
headers,
signal,
},
);
try {
(options.onServerVersion ?? observeServerVersion)(
response.headers.get(KUBER_VERSION_HEADER),
);
} catch {
// Version discovery must never interrupt an API request.
}
return response;
}
export async function apiRequest<T>(
@@ -288,3 +303,92 @@ export async function* apiStreamNdjson<T>(
deadline.clear();
}
}
/** Raised only when a server does not offer the SSE build-events representation. */
export class ApiStreamUnsupportedError extends Error {}
/** One authenticated SSE connection. The caller owns reconnection and its cursor. */
export async function* apiStreamEvents<T>(
path: string,
after: number,
signal?: AbortSignal,
options: ApiRequestOptions = {},
): AsyncGenerator<{ event: T; id?: number; type?: string }, void, void> {
const deadline = requestDeadline(signal, 0);
const headers = new Headers({ accept: "text/event-stream" });
headers.set("last-event-id", String(after));
try {
const response = await sendRequest(
path,
{ headers },
options,
deadline.signal,
);
if ([404, 405, 406, 415, 501].includes(response.status))
throw new ApiStreamUnsupportedError("Build SSE events are unavailable");
await assertResponseOk(response);
if (
!response.headers
.get("content-type")
?.toLowerCase()
.startsWith("text/event-stream")
) {
await response.body?.cancel();
throw new ApiStreamUnsupportedError("Build SSE events are unavailable");
}
if (!response.body) throw new Error("Empty build SSE response");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let data: string[] = [];
let eventType = "";
let id: number | undefined;
try {
for (;;) {
const { value, done } = await reader.read();
buffer += decoder.decode(value, { stream: !done });
while (buffer.includes("\n")) {
const newline = buffer.indexOf("\n");
const line = buffer.slice(0, newline).replace(/\r$/, "");
buffer = buffer.slice(newline + 1);
if (line === "") {
if (
data.length &&
(eventType === "log" ||
eventType === "status" ||
eventType === "gap")
)
yield {
event: JSON.parse(data.join("\n")) as T,
id,
...(eventType === "gap" && { type: eventType }),
};
data = [];
id = undefined;
eventType = "";
} else if (line.startsWith("data:"))
data.push(line.slice(5).replace(/^ /, ""));
else if (line.startsWith("event:")) eventType = line.slice(6).trim();
else if (line.startsWith("id:")) {
const raw = line.slice(3).trim();
if (/^(0|[1-9]\d*)$/.test(raw) && Number.isSafeInteger(Number(raw)))
id = Number(raw);
}
}
// Bound a malformed or non-SSE response that never terminates a line.
if (buffer.length > 2 * 1024 * 1024)
throw new Error("Build SSE line too long");
if (done) break;
}
} finally {
try {
await reader.cancel();
} catch {
/* Upstream aborted. */
}
reader.releaseLock();
}
} finally {
deadline.clear();
}
}
+93 -30
View File
@@ -12,7 +12,13 @@ import {
type Sha256Digest,
} from "../shared/build-protocol";
import { resolveComposeArch } from "./arch";
import { apiRequest, type ApiRequestInit, type ApiRequestOptions } from "./api";
import {
apiRequest,
apiStreamEvents,
ApiStreamUnsupportedError,
type ApiRequestInit,
type ApiRequestOptions,
} from "./api";
import { DEFAULT_REGISTRY } from "./config";
import {
enumerateWorkspace,
@@ -153,9 +159,17 @@ export type ApiRequester = <T>(
options?: ApiRequestOptions,
) => Promise<T>;
export type BuildEventStreamer = (
path: string,
after: number,
signal?: AbortSignal,
) => AsyncIterable<{ event: BuildEvent; id?: number; type?: string }>;
export type BuildOptions = {
registry?: string;
request?: ApiRequester;
/** For embedded clients; defaults to authenticated SSE with the standard API requester. */
streamEvents?: BuildEventStreamer;
pollIntervalMs?: number;
sleep?: (milliseconds: number) => Promise<void>;
snapshot?: WorkspaceSnapshot;
@@ -496,20 +510,78 @@ async function waitForBuild(
initial: BuildStatus,
signal?: AbortSignal,
prefixService = false,
streamEvents?: BuildEventStreamer,
): Promise<BuildStatus> {
let status = initial;
let sequence = 0;
let failureLog = "";
// Each physical build belongs only to its destination services. A shared
// reporter (the caller's global sink) receives each event just once.
const owners = new Map<BuildReporter | undefined, {
name: string;
reporter?: BuildReporter;
states: Set<string>;
}>();
const owners = new Map<
BuildReporter | undefined,
{
name: string;
reporter?: BuildReporter;
states: Set<string>;
}
>();
for (const { name, reporter } of reporters)
if (!owners.has(reporter))
owners.set(reporter, { name, reporter, states: new Set<string>() });
const deliver = async (event: BuildEvent, updateStatus = false) => {
if (event.type === "log" && event.sequence <= sequence) return;
if (event.type === "log")
failureLog = (failureLog + event.message).slice(-8_192);
for (const { name, reporter, states } of owners.values())
await reportBuildEvent(
event,
reporter,
states,
prefixService ? name : undefined,
);
if (event.type === "log") sequence = event.sequence;
else if (updateStatus) status = event.status;
};
if (streamEvents) {
const path = `/builds/${encodeURIComponent(id)}/events`;
let unsupported = false;
while (!unsupported) {
try {
signal?.throwIfAborted();
for await (const { event, id: cursor, type: eventType } of streamEvents(
path,
sequence,
signal,
)) {
if (eventType === "gap") {
const gap = event as unknown as { message?: string };
throw new Error(
gap.message ??
"Build log history was trimmed; some log output is unavailable.",
);
}
if (
event.type === "log" &&
cursor !== undefined &&
cursor !== event.sequence
)
throw new Error("Build SSE log cursor does not match its sequence");
await deliver(event, true);
if (status.state === "succeeded" || status.state === "failed") break;
}
if (status.state === "succeeded" || status.state === "failed") break;
} catch (error) {
if (error instanceof ApiStreamUnsupportedError) unsupported = true;
else if (signal?.aborted) throw signal.reason;
else if (!isTransientBuildPollError(error)) throw error;
}
if (!unsupported) {
signal?.throwIfAborted();
await sleep(Math.max(100, pollIntervalMs));
}
}
if (!unsupported) return withFailureLog(status, failureLog);
}
for (;;) {
const events = await requestBuildPoll<BuildEvent[]>(
request,
@@ -519,31 +591,9 @@ async function waitForBuild(
sleep,
signal,
);
for (const event of events) {
if (event.type === "log" && event.sequence <= sequence) continue;
if (event.type === "log")
failureLog = (failureLog + event.message).slice(-8_192);
for (const { name, reporter, states } of owners.values())
await reportBuildEvent(
event,
reporter,
states,
prefixService ? name : undefined,
);
if (event.type === "log") sequence = event.sequence;
}
for (const event of events) await deliver(event);
if (status.state === "succeeded" || status.state === "failed") {
const details = failureLog.trim();
if (
status.state === "failed" &&
details &&
!status.error?.includes(details)
)
return {
...status,
error: `${status.error ?? "BuildKit Job failed"}\n${details}`,
};
return status;
return withFailureLog(status, failureLog);
}
status = await requestBuildPoll<BuildStatus>(
request,
@@ -563,6 +613,18 @@ async function waitForBuild(
}
}
function withFailureLog(status: BuildStatus, failureLog: string): BuildStatus {
const details = failureLog.trim();
return status.state === "failed" &&
details &&
!status.error?.includes(details)
? {
...status,
error: `${status.error ?? "BuildKit Job failed"}\n${details}`,
}
: status;
}
export async function resolveBuildImages(
project: string,
compose: ComposeSpecification,
@@ -753,6 +815,7 @@ export async function buildServices(
initial,
options.signal,
!reporter?.service,
options.streamEvents ?? (options.request ? undefined : apiStreamEvents),
);
if (status.state !== "succeeded")
throw new Error(
+154 -34
View File
@@ -4,24 +4,65 @@ import type { ComposeSpecification, Service } from "../schema/docker.d";
import { LABELS } from "../const";
import { deleteResource, applyResource } from "./apply";
export const DATABASE_RECONCILE_PHASES = [
"namespace precheck", "database dependency", "database resource listing",
"database resource ownership", "claim discovery", "credential preparation",
"role secret lookup", "role secret apply", "cluster lookup",
"managed role preparation", "managed role update", "database preparation",
"database lookup", "database ownership", "database apply", "credential lookup",
"environment assembly", "operation execution",
] as const;
export class DatabaseReconciliationError extends Error {
readonly phase: string;
constructor(phase: string, error: unknown, claim?: PostgresClaim) {
const context = claim
? ` for database ${claim.database} (service ${claim.service}, role ${claim.username})`
: "";
const reason = error instanceof Error ? error.message : String(error);
// Provider errors can contain credentials or entire request bodies. Only
// expose a short, recognisable operational reason, never a raw response.
const knownReason = /^(forbidden|not found|conflict|permission denied|connection refused|timed out|timeout|unauthorized|unprocessable entity|service unavailable)\b/i.exec(reason);
const status = error && typeof error === "object" && "code" in error &&
typeof error.code === "number" && error.code >= 400 && error.code < 600
? ` (HTTP ${error.code})`
: "";
const safeReason = `${knownReason ? knownReason[1] : "Kubernetes request failed"}${status}`;
super(`Database reconciliation failed during ${phase}${context}: ${safeReason}`, {
const safePhase: string = DATABASE_RECONCILE_PHASES.some((known) => known === phase)
? phase
: "operation execution";
// Claim values are user-controlled and may themselves be credentials.
// Keep the actionable phase, but never persist or expose claim identifiers.
const context = claim ? " for requested database claim" : "";
const provider =
error && typeof error === "object"
? (error as Record<string, unknown>)
: {};
const body =
provider.body && typeof provider.body === "object"
? (provider.body as Record<string, unknown>)
: {};
const statusCode = [provider.statusCode, provider.code, body.code].find(
(value) =>
typeof value === "number" &&
Number.isInteger(value) &&
value >= 400 &&
value < 600,
);
const status = statusCode === undefined ? "" : ` (HTTP ${statusCode})`;
// Provider messages and response bodies can contain secrets, connection
// URLs or the full request. Match only a fixed vocabulary, never echo them.
const reason = error instanceof Error ? error.message : "";
const knownReason =
/^(forbidden|not found|conflict|permission denied|connection refused|timed out|timeout|unauthorized|unprocessable entity|service unavailable)\b/i.exec(
reason,
);
const providerReason =
typeof body.reason === "string"
? /^(Forbidden|NotFound|AlreadyExists|Conflict|Unauthorized|Invalid|ServiceUnavailable)$/.exec(
body.reason,
)?.[1]
: undefined;
const fallback = safePhase === "claim discovery"
? "Check PostgreSQL claim declarations"
: safePhase === "operation execution" && !status
? "Unexpected failure; check server logs using the operation ID"
: "Kubernetes request failed";
const safeReason = `${knownReason?.[1] ?? providerReason ?? fallback}${status}`;
super(`Database reconciliation failed during ${safePhase}${context}: ${safeReason}`, {
cause: error,
});
this.name = "DatabaseReconciliationError";
this.phase = safePhase;
}
}
@@ -210,8 +251,9 @@ async function readObject<T>(
async function ensureRoleSecret(
claim: PostgresClaim,
): Promise<RoleCredentials> {
let existing: V1Secret | undefined;
try {
const existing = await readObject<V1Secret>({
existing = await readObject<V1Secret>({
apiVersion: "v1",
kind: "Secret",
metadata: {
@@ -219,10 +261,12 @@ async function ensureRoleSecret(
namespace: DATABASE_NAMESPACE,
},
});
} catch (error) {
throw new DatabaseReconciliationError("role secret lookup", error, claim);
}
try {
const username = claim.username;
const password = decodeSecretValue(existing?.data?.password) ?? randomUUID();
await applyResource({
apiVersion: "v1",
kind: "Secret",
@@ -239,7 +283,7 @@ async function ensureRoleSecret(
return { username, password };
} catch (error) {
throw new DatabaseReconciliationError("role secret setup", error, claim);
throw new DatabaseReconciliationError("role secret apply", error, claim);
}
}
@@ -290,17 +334,22 @@ async function reconcileManagedRoles(
if (!cluster) {
throw new DatabaseReconciliationError(
`CNPG cluster ${DATABASE_NAMESPACE}/${DATABASE_CLUSTER} lookup`,
"cluster lookup",
new Error("Not found"),
claims[0],
);
}
const roles = new Map(
(cluster.spec?.managed?.roles ?? []).map((role) => [role.name, role]),
);
for (const claim of claims) {
roles.set(claim.username, toManagedRole(claim));
let roles: Map<string, ManagedRole>;
try {
roles = new Map(
(cluster.spec?.managed?.roles ?? []).map((role) => [role.name, role]),
);
for (const claim of claims) {
roles.set(claim.username, toManagedRole(claim));
}
} catch (error) {
throw new DatabaseReconciliationError("managed role preparation", error, claims[0]);
}
try {
@@ -326,17 +375,26 @@ async function reconcileManagedRoles(
async function reconcileDatabases(
project: string,
claims: PostgresClaim[],
existingDatabases: Set<string>,
signal?: AbortSignal,
): Promise<void> {
const uniqueDatabases = new Map<string, PostgresClaim>();
for (const claim of claims) {
uniqueDatabases.set(`${claim.database}:${claim.username}`, claim);
try {
for (const claim of claims) {
uniqueDatabases.set(`${claim.database}:${claim.username}`, claim);
}
} catch (error) {
throw new DatabaseReconciliationError("database preparation", error);
}
for (const claim of uniqueDatabases.values()) {
// An existing Database may be shared with other workspaces. Applying even
// an identical object can prune labels owned by the SSA field manager.
if (existingDatabases.has(claim.database)) continue;
try {
throwIfAborted(signal);
await applyResource({
const { objectApi } = await import("./k8s");
await objectApi.create({
apiVersion: "postgresql.cnpg.io/v1",
kind: "Database",
metadata: {
@@ -357,22 +415,63 @@ async function reconcileDatabases(
name: claim.database,
owner: claim.username,
},
});
} as KubernetesObject);
} catch (error) {
throw new DatabaseReconciliationError("database apply", error, claim);
}
}
}
async function inspectDatabases(
claims: PostgresClaim[],
signal?: AbortSignal,
): Promise<Set<string>> {
const existing = new Set<string>();
for (const claim of claims) {
if (existing.has(claim.database)) continue;
let database: (KubernetesObject & {
spec?: { owner?: string; cluster?: { name?: string } };
}) | undefined;
try {
throwIfAborted(signal);
database = await readObject({
apiVersion: "postgresql.cnpg.io/v1",
kind: "Database",
metadata: { name: claim.database, namespace: DATABASE_NAMESPACE },
});
} catch (error) {
throw new DatabaseReconciliationError("database lookup", error, claim);
}
if (!database) continue;
if (
database.spec?.owner !== claim.username ||
database.spec?.cluster?.name !== DATABASE_CLUSTER
) {
throw new DatabaseReconciliationError(
"database ownership",
new Error("Existing database does not match the requested role and cluster"),
claim,
);
}
existing.add(claim.database);
}
return existing;
}
export async function reconcilePostgresClaim(
project: string,
claim: PostgresClaim,
signal?: AbortSignal,
): Promise<RoleCredentials> {
throwIfAborted(signal);
try {
throwIfAborted(signal);
} catch (error) {
throw new DatabaseReconciliationError("credential preparation", error, claim);
}
const existing = await inspectDatabases([claim], signal);
const credentials = await ensureRoleSecret(claim);
await reconcileManagedRoles([claim], signal);
await reconcileDatabases(project, [claim], signal);
await reconcileDatabases(project, [claim], existing, signal);
return credentials;
}
@@ -381,27 +480,48 @@ export async function reconcilePostgresClaims(
compose: ComposeSpecification,
signal?: AbortSignal,
): Promise<Record<string, Record<string, string>>> {
const claims = getComposePostgresClaims(compose);
let claims: PostgresClaim[];
try {
claims = getComposePostgresClaims(compose);
} catch (error) {
throw new DatabaseReconciliationError("claim discovery", error);
}
if (claims.length === 0) return {};
// Validate every requested database before touching any role Secret or the
// shared Cluster; one conflicting claim must leave all roles untouched.
const existing = await inspectDatabases(claims, signal);
const credentialsBySecret = new Map<string, RoleCredentials>();
for (const claim of claims) {
if (credentialsBySecret.has(claim.secretName)) continue;
throwIfAborted(signal);
try {
throwIfAborted(signal);
} catch (error) {
throw new DatabaseReconciliationError("credential preparation", error, claim);
}
credentialsBySecret.set(claim.secretName, await ensureRoleSecret(claim));
}
await reconcileManagedRoles(claims, signal);
await reconcileDatabases(project, claims, signal);
await reconcileDatabases(project, claims, existing, signal);
return Object.fromEntries(
claims.map((claim) => {
const credentials = credentialsBySecret.get(claim.secretName);
if (!credentials) {
throw new Error(`Missing credentials for ${claim.secretName}`);
throw new DatabaseReconciliationError(
"credential lookup",
new Error("Missing credentials"),
claim,
);
}
return [claim.service, buildPostgresEnvironment(claim, credentials)];
try {
return [claim.service, buildPostgresEnvironment(claim, credentials)];
} catch (error) {
throw new DatabaseReconciliationError("environment assembly", error, claim);
}
}),
);
}
+229
View File
@@ -0,0 +1,229 @@
import { KUBER_VERSION } from "../shared/version";
import { readlinkSync, statSync } from "node:fs";
type SemVer = {
major: bigint;
minor: bigint;
patch: bigint;
prerelease: string[];
};
const SEMVER =
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
const NUMERIC = /^(0|[1-9]\d*)$/;
const INSTALL_TIMEOUT_SECONDS = 30;
const gray = (line: string) => `\x1b[90m${line}\x1b[0m\n`;
const reportToStderr = (line: string) => process.stderr.write(line);
// This process owns the install result when the invoking CLI has already exited.
// It opens only the original terminal (never the parent's pipe or stdout), and
// checks its identity before writing in case the pty path has been recycled.
const TTY_HELPER = `
const { openSync, closeSync, fstatSync, writeSync, constants } = require('node:fs');
const [version, seconds, path, dev, ino, rdev, uid] = process.argv.slice(1);
let success = false;
try {
const child = Bun.spawn(['timeout', '--signal=TERM', '--kill-after=2s', seconds + 's',
'bun', 'i', '-g', '--no-cache', '@dmgnr/kuber@' + version],
{ stdin: 'ignore', stdout: 'ignore', stderr: 'ignore' });
success = (await child.exited) === 0;
} catch {}
try {
const fd = openSync(path, constants.O_WRONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
try {
const stat = fstatSync(fd);
if (stat.isCharacterDevice() && String(stat.dev) === dev &&
String(stat.ino) === ino && String(stat.rdev) === rdev && String(stat.uid) === uid) {
writeSync(fd, '\\x1b[90m+ ' + (success ? 'Updated to ' : 'New version available: ') +
version + '\\x1b[0m\\n');
}
} finally { closeSync(fd); }
} catch {}
`;
function originalTerminal(): string[] | undefined {
if (!process.stderr.isTTY) return;
try {
const path = readlinkSync("/proc/self/fd/2");
if (!/^\/dev\/pts\/[0-9]+$/.test(path)) return;
const stat = statSync(path);
if (!stat.isCharacterDevice()) return;
return [
path,
String(stat.dev),
String(stat.ino),
String(stat.rdev),
String(stat.uid),
];
} catch {
return;
}
}
function installWithTerminalReport(version: string, tty: string[]): void {
const child = Bun.spawn(
[
process.execPath,
"-e",
TTY_HELPER,
version,
String(INSTALL_TIMEOUT_SECONDS),
...tty,
],
{ stdin: "ignore", stdout: "ignore", stderr: "ignore", detached: true },
);
child.unref();
}
function parseVersion(value: string | null): SemVer | undefined {
if (!value || value.length > 128) return;
const match = SEMVER.exec(value);
if (!match || match[0] !== value) return;
const prerelease = match[4]?.split(".") ?? [];
if (prerelease.some((part) => /^\d+$/.test(part) && !NUMERIC.test(part)))
return;
return {
major: BigInt(match[1]!),
minor: BigInt(match[2]!),
patch: BigInt(match[3]!),
prerelease,
};
}
/** A positive result means candidate is newer; build metadata has no precedence. */
export function compareVersions(
candidate: string,
current: string,
): number | undefined {
const a = parseVersion(candidate);
const b = parseVersion(current);
if (!a || !b) return;
for (const field of ["major", "minor", "patch"] as const) {
if (a[field] !== b[field]) return a[field] > b[field] ? 1 : -1;
}
if (!a.prerelease.length || !b.prerelease.length) {
return Number(!a.prerelease.length) - Number(!b.prerelease.length);
}
for (let i = 0; i < Math.max(a.prerelease.length, b.prerelease.length); i++) {
const left = a.prerelease[i];
const right = b.prerelease[i];
if (left === undefined || right === undefined)
return left === undefined ? -1 : 1;
if (left === right) continue;
const leftNumeric = NUMERIC.test(left);
const rightNumeric = NUMERIC.test(right);
if (leftNumeric && rightNumeric)
return BigInt(left) > BigInt(right) ? 1 : -1;
if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1;
return left < right ? -1 : 1;
}
return 0;
}
export type VersionInstallRunner = (version: string) => Promise<boolean>;
type InstallProcess = {
exited: Promise<number>;
unref?(): void;
};
type InstallSpawn = (
argv: string[],
options: {
stdin: "ignore";
stdout: "ignore";
stderr: "ignore";
detached: true;
},
) => InstallProcess;
export async function installVersion(
version: string,
spawn: InstallSpawn = Bun.spawn,
timeoutSeconds = INSTALL_TIMEOUT_SECONDS,
): Promise<boolean> {
// Defense in depth: never pass an unvalidated header to a subprocess.
if (
!parseVersion(version) ||
!Number.isSafeInteger(timeoutSeconds) ||
timeoutSeconds < 1 ||
timeoutSeconds > 300
)
return false;
const child = spawn(
[
"timeout",
"--signal=TERM",
"--kill-after=2s",
`${timeoutSeconds}s`,
"bun",
"i",
"-g",
"--no-cache",
`@dmgnr/kuber@${version}`,
],
{ stdin: "ignore", stdout: "ignore", stderr: "ignore", detached: true },
);
// The detached timeout owns its bounded lifetime; it must not keep a short
// CLI invocation alive. Its result can still be observed while the parent lives.
child.unref?.();
return child.exited.then((code) => code === 0);
}
export function createVersionObserver({
currentVersion = KUBER_VERSION,
runner = installVersion,
report = reportToStderr,
}: {
currentVersion?: string;
runner?: VersionInstallRunner;
report?: (line: string) => void;
} = {}): (version: string | null) => void {
let attempted = false;
return (version) => {
if (attempted || !version || compareVersions(version, currentVersion) !== 1)
return;
attempted = true;
// Defer install work beyond the response headers; never block body consumption.
setTimeout(() => {
// The global observer must not install packages in tests or source-tree
// development commands. Explicitly injected runners remain testable.
if (
runner === installVersion &&
(process.env.NODE_ENV === "test" ||
process.env.NODE_ENV === "development" ||
process.argv[1]?.endsWith(".ts"))
)
return;
if (runner === installVersion && report === reportToStderr) {
const tty = originalTerminal();
if (tty) {
try {
installWithTerminalReport(version, tty);
} catch {
try {
report(gray(`+ New version available: ${version}`));
} catch {}
}
return;
}
}
void Promise.resolve()
.then(() => runner(version))
.then(
(success) => {
report(
gray(
`+ ${success ? "Updated to " : "New version available: "}${version}`,
),
);
},
() => report(gray(`+ New version available: ${version}`)),
)
.catch(() => {
// A broken stderr must never affect an API request or command exit status.
});
}, 0);
};
}
export const observeServerVersion = createVersionObserver();