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();
}
}