Fix terminal log follow and wrapping
This commit is contained in:
@@ -802,6 +802,28 @@ describe("PlatformApiClient AI providers", () => {
|
||||
stream.close();
|
||||
});
|
||||
|
||||
it("reconnects terminal log SSE after the fetch stream ends", async () => {
|
||||
const payloads = [
|
||||
"id: log-1:3\nevent: log\ndata: {\"streamId\":\"log-1\",\"entry\":{\"seq\":3,\"line\":\"first live line\"}}\n\n",
|
||||
"id: log-1:4\nevent: log\ndata: {\"streamId\":\"log-1\",\"entry\":{\"seq\":4,\"line\":\"second live line\"}}\n\n"
|
||||
];
|
||||
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => new Response(new ReadableStream<Uint8Array>({ start(controller) { controller.enqueue(new TextEncoder().encode(payloads[Math.min(fetchMock.mock.calls.length - 1, payloads.length - 1)])); controller.close(); } }), { status: 200, headers: { "Content-Type": "text/event-stream" } }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const client = new PlatformApiClient("/api/v1", () => "terminal-session");
|
||||
const events: string[] = [];
|
||||
const stream = client.openServerLogEvents("server-1");
|
||||
stream.addEventListener("log", (event) => events.push(event.data));
|
||||
|
||||
await vi.waitFor(() => expect(events).toContain('{"streamId":"log-1","entry":{"seq":3,"line":"first live line"}}'));
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2), { timeout: 1500 });
|
||||
expect(new Headers(fetchMock.mock.calls[1]?.[1]?.headers).get("Last-Event-ID")).toBe("log-1:3");
|
||||
await vi.waitFor(() => expect(events).toEqual([
|
||||
'{"streamId":"log-1","entry":{"seq":3,"line":"first live line"}}',
|
||||
'{"streamId":"log-1","entry":{"seq":4,"line":"second live line"}}'
|
||||
]), { timeout: 1500 });
|
||||
stream.close();
|
||||
});
|
||||
|
||||
it("surfaces password confirmation denials without exposing generic forbidden text", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({
|
||||
code: "forbidden",
|
||||
|
||||
+38
-17
@@ -791,11 +791,12 @@ async function responseError(response: Response): Promise<PlatformApiError> {
|
||||
|
||||
class FetchServerSentEventStream implements PlatformEventStream {
|
||||
onerror: ((event: Event) => void) | null = null;
|
||||
private lastEventId = "";
|
||||
private readonly controller = new AbortController();
|
||||
private readonly listeners = new Map<string, Set<(event: MessageEvent) => void>>();
|
||||
|
||||
constructor(private readonly url: string, private readonly sessionToken: string) {
|
||||
void this.connect();
|
||||
void this.connectLoop();
|
||||
}
|
||||
|
||||
addEventListener(type: string, listener: (event: MessageEvent) => void): void {
|
||||
@@ -813,17 +814,21 @@ class FetchServerSentEventStream implements PlatformEventStream {
|
||||
this.listeners.clear();
|
||||
}
|
||||
|
||||
private async connect(): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(this.url, { method: "GET", credentials: "include", headers: { Accept: "text/event-stream", Authorization: `Bearer ${this.sessionToken}` }, signal: this.controller.signal });
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`event stream failed: ${response.status}`);
|
||||
}
|
||||
await this.read(response.body);
|
||||
} catch {
|
||||
if (!this.controller.signal.aborted) {
|
||||
private async connectLoop(): Promise<void> {
|
||||
while (!this.controller.signal.aborted) {
|
||||
try {
|
||||
const headers = new Headers({ Accept: "text/event-stream", Authorization: `Bearer ${this.sessionToken}` });
|
||||
if (this.lastEventId) headers.set("Last-Event-ID", this.lastEventId);
|
||||
const response = await fetch(this.url, { method: "GET", credentials: "include", headers, signal: this.controller.signal });
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`event stream failed: ${response.status}`);
|
||||
}
|
||||
await this.read(response.body);
|
||||
} catch {
|
||||
if (this.controller.signal.aborted) return;
|
||||
this.onerror?.(new Event("error"));
|
||||
}
|
||||
await this.reconnectPause();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -853,23 +858,39 @@ class FetchServerSentEventStream implements PlatformEventStream {
|
||||
if (field === "id") eventId = value;
|
||||
if (field === "data") dataLines.push(value);
|
||||
};
|
||||
while (!this.controller.signal.aborted) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
lines.forEach(processLine);
|
||||
try {
|
||||
while (!this.controller.signal.aborted) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
lines.forEach(processLine);
|
||||
}
|
||||
buffer += decoder.decode();
|
||||
if (buffer) processLine(buffer);
|
||||
processLine("");
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
private dispatch(type: string, data: string, lastEventId: string): void {
|
||||
if (lastEventId) this.lastEventId = lastEventId;
|
||||
const event = new MessageEvent(type, { data, lastEventId });
|
||||
this.listeners.get(type)?.forEach((listener) => listener(event));
|
||||
if (type !== "message") {
|
||||
this.listeners.get("message")?.forEach((listener) => listener(event));
|
||||
}
|
||||
}
|
||||
|
||||
private reconnectPause(): Promise<void> {
|
||||
if (this.controller.signal.aborted) return Promise.resolve();
|
||||
return new Promise((resolve) => {
|
||||
const timeout = globalThis.setTimeout(resolve, 1000);
|
||||
this.controller.signal.addEventListener("abort", () => { globalThis.clearTimeout(timeout); resolve(); }, { once: true });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function safeValidationMessage(apiError?: ApiErrorResponse | null): string {
|
||||
|
||||
Reference in New Issue
Block a user