feat: prepare 2.6.1-rc5 shared databases and build SSE
This commit is contained in:
+110
-6
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user