From d45c44e4ebe0d81b0ebd00d0afb6d4f5ee1e375f Mon Sep 17 00:00:00 2001 From: chowon442 Date: Tue, 20 Oct 2026 04:33:05 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20fetch=20=EA=B8=B0=EB=B0=98=20run=20?= =?UTF-8?q?SSE=20reader=EC=99=80=20run=20API=20client=C2=B7=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80=20(1.89)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sse_reader: CR/LF/CRLF·청크 경계 처리, heartbeat comment 무시, 종료 사유(terminal·interrupted·aborted·rejected) 구분 - run_api_client: POST/GET SSE, 상태·취소·대화 조회, stream 전 401 1회 갱신 - run_api_error: ApiResponse 오류를 status·code·activeRunId·Retry-After로 해석 - run_request: requestId 생성, Python str.strip() 집합·code point 길이 검증 - client.ts: interceptor와 대기큐를 공유하는 refreshAccessToken export Co-Authored-By: Claude Opus 5.5 --- src/features/run/api/index.ts | 42 ++ src/features/run/api/run_api_client.test.ts | 442 ++++++++++++++++++++ src/features/run/api/run_api_client.ts | 254 +++++++++++ src/features/run/api/run_api_error.test.ts | 109 +++++ src/features/run/api/run_api_error.ts | 135 ++++++ src/features/run/api/run_request.test.ts | 265 ++++++++++++ src/features/run/api/run_request.ts | 337 +++++++++++++++ src/features/run/api/sse_reader.test.ts | 405 ++++++++++++++++++ src/features/run/api/sse_reader.ts | 264 ++++++++++++ src/features/run/api/test_streams.ts | 92 ++++ src/shared/api/client.test.ts | 59 +++ src/shared/api/client.ts | 37 ++ 12 files changed, 2441 insertions(+) create mode 100644 src/features/run/api/index.ts create mode 100644 src/features/run/api/run_api_client.test.ts create mode 100644 src/features/run/api/run_api_client.ts create mode 100644 src/features/run/api/run_api_error.test.ts create mode 100644 src/features/run/api/run_api_error.ts create mode 100644 src/features/run/api/run_request.test.ts create mode 100644 src/features/run/api/run_request.ts create mode 100644 src/features/run/api/sse_reader.test.ts create mode 100644 src/features/run/api/sse_reader.ts create mode 100644 src/features/run/api/test_streams.ts create mode 100644 src/shared/api/client.test.ts diff --git a/src/features/run/api/index.ts b/src/features/run/api/index.ts new file mode 100644 index 0000000..5e0fb66 --- /dev/null +++ b/src/features/run/api/index.ts @@ -0,0 +1,42 @@ +export { createRunApiClient, isEventId, runApi } from "./run_api_client"; +export type { + ConversationMessagesQuery, + RunApiAuth, + RunApiClient, + RunApiClientOptions, + RunEventsCursor, + RunStreamOptions, +} from "./run_api_client"; +export { + parseRetryAfter, + readRunApiError, + RunApiError, + toRunApiError, +} from "./run_api_error"; +export type { RunApiErrorDetail } from "./run_api_error"; +export { + countCodePoints, + createConversationRequest, + createRequestId, + isNoteId, + isUuid, + stripPythonWhitespace, + validateConversationRequest, +} from "./run_request"; +export type { + ConversationQuestionInput, + ConversationRequestCheck, + RequestValidationCode, + RequestValidationIssue, +} from "./run_request"; +export { + createSseParser, + readRunStream, + SseProtocolError, + toBrowserFrame, +} from "./sse_reader"; +export type { + RunStreamEnd, + RunStreamReadOptions, + SseMessage, +} from "./sse_reader"; diff --git a/src/features/run/api/run_api_client.test.ts b/src/features/run/api/run_api_client.test.ts new file mode 100644 index 0000000..bce0720 --- /dev/null +++ b/src/features/run/api/run_api_client.test.ts @@ -0,0 +1,442 @@ +// @vitest-environment node +import { describe, expect, it, vi } from "vitest"; +import { toEventId } from "@/shared/contracts/browser_run_v1"; +import type { + BrowserFrame, + ConversationRunRequest, +} from "@/shared/contracts/browser_run_v1"; +import { + getConversationFixture, + getCursorCases, + getErrorFixture, + getRequestFixture, + getRunCancelFixture, + getRunStatusFixture, + getStreamFixture, +} from "@/test/contracts/browser_run_fixtures"; +import { createRunApiClient, isEventId } from "./run_api_client"; +import type { RunApiAuth } from "./run_api_client"; +import { RunApiError } from "./run_api_error"; +import { createConversationRequest } from "./run_request"; +import { jsonResponse, sseResponse } from "./test_streams"; + +const BASE_URL = "http://localhost:8080"; + +interface SentRequest { + url: string; + method: string; + headers: Headers; + body: string | null; +} + +const setup = ( + responses: (Response | Error)[], + auth: RunApiAuth = { getAccessToken: () => "access-1" }, +) => { + const sent: SentRequest[] = []; + const fetchMock = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + sent.push({ + url: String(input), + method: init?.method ?? "GET", + headers: new Headers(init?.headers), + body: (init?.body as string | undefined) ?? null, + }); + if (init?.signal?.aborted) + throw new DOMException("aborted", "AbortError"); + const next = responses.shift(); + if (!next) throw new Error("응답 없음"); + if (next instanceof Error) throw next; + return next; + }, + ); + const client = createRunApiClient({ + baseUrl: BASE_URL, + fetch: fetchMock as typeof fetch, + auth, + }); + return { client, sent, fetchMock }; +}; + +const collect = () => { + const frames: BrowserFrame[] = []; + return { frames, onFrame: (frame: BrowserFrame) => frames.push(frame) }; +}; + +/** 첫 frame 블록만 남긴다(빈 줄 포함). */ +const splitFirstBlock = (sse: string) => `${sse.split("\n\n")[0]}\n\n`; + +const stream = getStreamFixture("substantive-completed"); +const lastId = toEventId(stream.runId, stream.frames.length); + +describe("streamConversation — POST /api/conversations", () => { + it("Authorization·Accept·closed body를 붙여 보내고 frame을 terminal까지 읽는다", async () => { + const request = getRequestFixture("existing-note-full").request; + const { client, sent } = setup([sseResponse([stream.sse])]); + const { frames, onFrame } = collect(); + const onOpen = vi.fn(); + + const end = await client.streamConversation(request, { onFrame, onOpen }); + + expect(sent).toHaveLength(1); + expect(sent[0].url).toBe(`${BASE_URL}/api/conversations`); + expect(sent[0].method).toBe("POST"); + expect(sent[0].headers.get("Authorization")).toBe("Bearer access-1"); + expect(sent[0].headers.get("Accept")).toBe("text/event-stream"); + expect(sent[0].headers.get("Content-Type")).toBe("application/json"); + expect(JSON.parse(sent[0].body!)).toEqual(request); + expect(onOpen).toHaveBeenCalledOnce(); + expect(frames).toEqual(stream.frames); + expect(end).toMatchObject({ reason: "terminal", lastEventId: lastId }); + }); + + it("첫 질문 body에는 noteId가 없다", async () => { + const { client, sent } = setup([sseResponse([stream.sse])]); + const check = createConversationRequest({ text: "풀어줘" }); + if (!check.ok) throw new Error("요청 생성 실패"); + await client.streamConversation(check.request, collect()); + expect(Object.keys(JSON.parse(sent[0].body!))).toEqual([ + "requestId", + "text", + ]); + }); + + it("같은 전송 재시도는 같은 requestId body를 보낸다", async () => { + const { client, sent } = setup([ + sseResponse([splitFirstBlock(stream.sse)], { end: "error" }), + sseResponse([stream.sse]), + ]); + const check = createConversationRequest({ noteId: "123", text: "풀어줘" }); + if (!check.ok) throw new Error("요청 생성 실패"); + const first = await client.streamConversation(check.request, collect()); + expect(first).toMatchObject({ reason: "interrupted", cause: "network" }); + await client.streamConversation(check.request, collect()); + expect(sent[1].body).toBe(sent[0].body); + }); + + it("계약에 맞지 않는 요청은 보내지 않는다", () => { + const { client, fetchMock } = setup([]); + const invalid = { + requestId: "7f460a7b-bf54-570e-a3af-b63a26435627", + text: "a", + locale: "ko", + } as unknown as ConversationRunRequest; + expect(() => client.streamConversation(invalid, collect())).toThrow( + TypeError, + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("409 RUN4091 → rejected, activeRunId", async () => { + const fixture = getErrorFixture("RUN4091"); + const { client } = setup([jsonResponse(fixture.http, fixture.body)]); + const { frames, onFrame } = collect(); + const onOpen = vi.fn(); + const end = await client.streamConversation( + getRequestFixture("first-no-note").request, + { onFrame, onOpen }, + ); + expect(end.reason).toBe("rejected"); + if (end.reason === "rejected") { + expect(end.error).toBeInstanceOf(RunApiError); + expect(end.error.detail).toMatchObject({ + status: 409, + code: "RUN4091", + activeRunId: + fixture.body.code === "RUN4091" && fixture.body.result.activeRunId, + }); + } + expect(frames).toEqual([]); + expect(onOpen).not.toHaveBeenCalled(); + }); + + it("429 RUN4291 → rejected, Retry-After", async () => { + const fixture = getErrorFixture("RUN4291"); + const { client } = setup([ + jsonResponse(fixture.http, fixture.body, { "Retry-After": "12" }), + ]); + const end = await client.streamConversation( + getRequestFixture("first-no-note").request, + collect(), + ); + expect(end).toMatchObject({ + reason: "rejected", + error: { + detail: { status: 429, code: "RUN4291", retryAfterSeconds: 12 }, + }, + }); + }); + + it("200인데 SSE가 아니면 계약 밖 rejected", async () => { + const { client } = setup([jsonResponse(200, { isSuccess: true })]); + const end = await client.streamConversation( + getRequestFixture("first-no-note").request, + collect(), + ); + expect(end).toMatchObject({ + reason: "rejected", + error: { detail: { code: null, status: 200 } }, + }); + }); + + it("fetch 실패는 interrupted(network), abort는 aborted", async () => { + const request = getRequestFixture("first-no-note").request; + const failing = setup([new TypeError("Failed to fetch")]); + expect( + await failing.client.streamConversation(request, collect()), + ).toMatchObject({ + reason: "interrupted", + cause: "network", + lastEventId: null, + }); + + const controller = new AbortController(); + controller.abort(); + const aborted = setup([sseResponse([stream.sse])]); + expect( + await aborted.client.streamConversation(request, { + ...collect(), + signal: controller.signal, + }), + ).toEqual({ reason: "aborted", lastEventId: null }); + }); +}); + +describe("401 token 갱신", () => { + const unauthorized = () => + jsonResponse(401, getErrorFixture("AUTH4012").body); + + it("stream 전 401이면 한 번 갱신하고 새 token으로 같은 요청을 보낸다", async () => { + let token = "expired"; + const refreshAccessToken = vi.fn(async () => (token = "access-2")); + const { client, sent } = setup( + [unauthorized(), sseResponse([stream.sse])], + { + getAccessToken: () => token, + refreshAccessToken, + }, + ); + const request = getRequestFixture("first-no-note").request; + const end = await client.streamConversation(request, collect()); + + expect(end.reason).toBe("terminal"); + expect(refreshAccessToken).toHaveBeenCalledOnce(); + expect(sent.map((item) => item.headers.get("Authorization"))).toEqual([ + "Bearer expired", + "Bearer access-2", + ]); + expect(sent[1].body).toBe(sent[0].body); + }); + + it("갱신 실패면 처음 401을 typed 오류로 돌려준다", async () => { + const { client, sent } = setup([unauthorized()], { + getAccessToken: () => "expired", + refreshAccessToken: () => Promise.reject(new Error("refresh 실패")), + }); + const end = await client.streamConversation( + getRequestFixture("first-no-note").request, + collect(), + ); + expect(end).toMatchObject({ + reason: "rejected", + error: { detail: { status: 401, code: "AUTH4012" } }, + }); + expect(sent).toHaveLength(1); + }); + + it("재시도도 401이면 다시 갱신하지 않는다", async () => { + const refreshAccessToken = vi.fn(async () => "access-2"); + const { client } = setup([unauthorized(), unauthorized()], { + getAccessToken: () => "expired", + refreshAccessToken, + }); + await expect(client.getRunStatus(stream.runId)).rejects.toMatchObject({ + detail: { status: 401, code: "AUTH4012" }, + }); + expect(refreshAccessToken).toHaveBeenCalledOnce(); + }); + + it("token이 없으면 Authorization을 붙이지 않는다", async () => { + const { client, sent } = setup( + [jsonResponse(401, getErrorFixture("AUTH4010").body)], + { getAccessToken: () => null }, + ); + await expect(client.getRunStatus(stream.runId)).rejects.toMatchObject({ + detail: { code: "AUTH4010" }, + }); + expect(sent[0].headers.has("Authorization")).toBe(false); + }); +}); + +describe("streamRunEvents — GET /api/ai-runs/{runId}/events", () => { + it("cursor 없이 seq 1부터 읽는다", async () => { + const { client, sent } = setup([sseResponse([stream.sse])]); + const { frames, onFrame } = collect(); + const end = await client.streamRunEvents(stream.runId, {}, { onFrame }); + expect(sent[0].url).toBe(`${BASE_URL}/api/ai-runs/${stream.runId}/events`); + expect(sent[0].method).toBe("GET"); + expect(sent[0].headers.has("Last-Event-ID")).toBe(false); + expect(sent[0].headers.get("Accept")).toBe("text/event-stream"); + expect(frames).toEqual(stream.frames); + expect(end).toMatchObject({ reason: "terminal", lastEventId: lastId }); + }); + + it("cursor는 기본 Last-Event-ID header로 보내고, 이어지는 frame만 받는다", async () => { + const cursor = toEventId(stream.runId, 3); + const rest = stream.sse.split("\n\n").slice(3).join("\n\n"); + const { client, sent } = setup([sseResponse([rest])]); + const { frames, onFrame } = collect(); + const end = await client.streamRunEvents( + stream.runId, + { lastEventId: cursor }, + { onFrame }, + ); + expect(sent[0].headers.get("Last-Event-ID")).toBe(cursor); + expect(sent[0].url).not.toContain("lastEventId"); + expect(frames).toEqual(stream.frames.slice(3)); + expect(end).toMatchObject({ reason: "terminal", lastEventId: lastId }); + }); + + it("placement query면 ?lastEventId=로 보낸다", async () => { + const cursor = toEventId(stream.runId, 3); + const { client, sent } = setup([sseResponse([])]); + await client.streamRunEvents( + stream.runId, + { lastEventId: cursor, placement: "query" }, + collect(), + ); + const url = new URL(sent[0].url); + expect(url.pathname).toBe(`/api/ai-runs/${stream.runId}/events`); + expect(url.searchParams.get("lastEventId")).toBe(cursor); + expect(sent[0].headers.has("Last-Event-ID")).toBe(false); + }); + + it("terminal run에 마지막 cursor로 붙으면 0 frame 종료 — cursor 그대로", async () => { + const { client } = setup([sseResponse([])]); + const end = await client.streamRunEvents( + stream.runId, + { lastEventId: lastId }, + collect(), + ); + expect(end).toEqual({ + reason: "interrupted", + cause: "eof", + lastEventId: lastId, + }); + }); + + it("400 RUN4003·404 RUN4041은 rejected", async () => { + for (const code of ["RUN4003", "RUN4041"] as const) { + const fixture = getErrorFixture(code); + const { client } = setup([jsonResponse(fixture.http, fixture.body)]); + const end = await client.streamRunEvents( + stream.runId, + { lastEventId: lastId }, + collect(), + ); + expect(end).toMatchObject({ + reason: "rejected", + error: { detail: { code, status: fixture.http } }, + lastEventId: lastId, + }); + } + }); + + it("cursor 판정표: schema가 거부하는 값은 isEventId가 거부하고 보내지 않는다", async () => { + const { runs, cases } = getCursorCases(); + for (const testCase of cases) { + const runId = runs[testCase.run]?.runId ?? stream.runId; + for (const raw of [ + testCase.lastEventIdHeader, + testCase.lastEventIdQuery, + ]) { + if (raw == null) continue; + const value = raw.replaceAll("{runId}", runId); + expect( + isEventId(value), + `${testCase.name}: ${JSON.stringify(value)}`, + ).toBe(!testCase.schemaRejects); + } + } + const { client, fetchMock } = setup([]); + expect(() => + client.streamRunEvents( + stream.runId, + { lastEventId: `${stream.runId}:03` }, + collect(), + ), + ).toThrow(TypeError); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe("JSON API", () => { + it("getRunStatus — GET /api/ai-runs/{runId} → result", async () => { + const fixture = getRunStatusFixture("status-running"); + const { client, sent } = setup([jsonResponse(200, fixture.body)]); + const status = await client.getRunStatus(fixture.body.result.runId); + expect(sent[0]).toMatchObject({ + url: `${BASE_URL}/api/ai-runs/${fixture.body.result.runId}`, + method: "GET", + body: null, + }); + expect(sent[0].headers.get("Accept")).toBe("application/json"); + expect(status).toEqual(fixture.body.result); + }); + + it("cancelRun — POST /api/ai-runs/{runId}/cancel, body·Content-Type 없음", async () => { + const fixture = getRunCancelFixture("cancel-accepted-running"); + const { client, sent } = setup([jsonResponse(200, fixture.body)]); + const result = await client.cancelRun(fixture.body.result.runId); + expect(sent[0]).toMatchObject({ + url: `${BASE_URL}/api/ai-runs/${fixture.body.result.runId}/cancel`, + method: "POST", + body: null, + }); + expect(sent[0].headers.has("Content-Type")).toBe(false); + expect(sent[0].headers.get("Authorization")).toBe("Bearer access-1"); + expect(result).toEqual(fixture.body.result); + }); + + it("getConversationMessages — before·limit query", async () => { + const fixture = getConversationFixture("conversation-completed"); + const page = fixture.body.result; + const before = page.messages[0].messageId; + const { client, sent } = setup([ + jsonResponse(200, fixture.body), + jsonResponse(200, fixture.body), + ]); + + expect(await client.getConversationMessages(page.noteId)).toEqual(page); + expect(sent[0].url).toBe( + `${BASE_URL}/api/conversations/${page.noteId}/messages`, + ); + + await client.getConversationMessages(page.noteId, { before, limit: 20 }); + const url = new URL(sent[1].url); + expect(url.pathname).toBe(`/api/conversations/${page.noteId}/messages`); + expect(url.searchParams.get("before")).toBe(before); + expect(url.searchParams.get("limit")).toBe("20"); + }); + + it("오류 응답은 RunApiError로 던진다", async () => { + const fixture = getErrorFixture("RUN4041"); + const { client } = setup([jsonResponse(fixture.http, fixture.body)]); + const error = await client + .cancelRun(stream.runId) + .catch((caught) => caught); + expect(error).toBeInstanceOf(RunApiError); + expect(error.detail).toEqual({ + code: "RUN4041", + status: 404, + message: fixture.body.message, + }); + }); + + it("200인데 isSuccess가 아니면 계약 밖 RunApiError", async () => { + const { client } = setup([new Response("", { status: 200 })]); + await expect(client.getRunStatus(stream.runId)).rejects.toMatchObject({ + detail: { code: null, status: 200 }, + }); + }); +}); diff --git a/src/features/run/api/run_api_client.ts b/src/features/run/api/run_api_client.ts new file mode 100644 index 0000000..d3fd4d2 --- /dev/null +++ b/src/features/run/api/run_api_client.ts @@ -0,0 +1,254 @@ +import { refreshAccessToken, tokenUtils } from "@/shared/api/client"; +import type { + ApiSuccessResponse, + BrowserFrame, + ConversationPage, + ConversationRunRequest, + EventId, + NoteId, + RunCancelResult, + RunStatus, + Uuid, +} from "@/shared/contracts/browser_run_v1"; +import { readRunApiError, RunApiError } from "./run_api_error"; +import { validateConversationRequest } from "./run_request"; +import { readRunStream } from "./sse_reader"; +import type { RunStreamEnd } from "./sse_reader"; + +/** + * browser-run-v1 run API client (S07 Server 경로). + * + * - SSE 함수(`streamConversation`·`streamRunEvents`)는 던지지 않고 `RunStreamEnd`로 한 번의 연결 결과를 돌려준다. + * - JSON 함수는 성공 시 `result`를 돌려주고, 오류 응답은 `RunApiError`로 던진다. + * fetch 자체 실패(네트워크·abort)는 fetch의 오류(`TypeError`·`AbortError`)를 그대로 던진다. + * - 모든 요청은 `Authorization: Bearer`를 붙이고, stream 전 401이면 token을 한 번 갱신해 같은 요청을 다시 보낸다. + */ + +export interface RunApiAuth { + getAccessToken: () => string | null; + /** 새 access token. 없으면 401을 그대로 돌려준다. */ + refreshAccessToken?: () => Promise; +} + +export interface RunApiClientOptions { + /** 기본 `VITE_API_BASE_URL` (개발은 빈 값 → vite proxy) */ + baseUrl?: string; + fetch?: typeof fetch; + auth?: RunApiAuth; +} + +export interface RunStreamOptions { + /** frame마다 순서대로 한 번 부른다. */ + onFrame: (frame: BrowserFrame) => void; + /** 200 SSE 응답을 받은 직후(첫 frame 전) 부른다. */ + onOpen?: () => void; + signal?: AbortSignal; +} + +export interface RunEventsCursor { + /** 마지막으로 처리한 frame의 `id`(`{runId}:{seq}`). 없으면 seq 1부터 받는다. */ + lastEventId?: EventId | null; + /** cursor를 실을 곳. 기본 `header`(`Last-Event-ID`). header를 못 쓰는 환경이면 `query`(`?lastEventId=`). */ + placement?: "header" | "query"; +} + +export interface ConversationMessagesQuery { + /** 이 messageId보다 이전 메시지 (이전 페이지의 `nextBefore`) */ + before?: Uuid | null; + /** 1..100, 기본 50(Server) */ + limit?: number; +} + +// schema `EventId` 패턴: 소문자 UUID + `:` + 선행 0 없는 seq. 상한(2^53-1)은 숫자로 따로 본다. +const EVENT_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}:[1-9][0-9]{0,15}(?![\s\S])/; + +export const isEventId = (value: unknown): value is EventId => + typeof value === "string" && + EVENT_ID_PATTERN.test(value) && + Number(value.slice(value.indexOf(":") + 1)) <= Number.MAX_SAFE_INTEGER; + +const SSE_ACCEPT = "text/event-stream"; +const JSON_ACCEPT = "application/json"; + +export const createRunApiClient = ({ + baseUrl = import.meta.env.VITE_API_BASE_URL || "", + fetch: fetchImpl = (...args) => globalThis.fetch(...args), + auth = { + getAccessToken: tokenUtils.getAccessToken, + refreshAccessToken, + }, +}: RunApiClientOptions = {}) => { + const send = async (path: string, init: RequestInit) => { + const sendWith = (token: string | null) => { + const headers = new Headers(init.headers); + if (token) headers.set("Authorization", `Bearer ${token}`); + return fetchImpl(`${baseUrl}${path}`, { ...init, headers }); + }; + + const response = await sendWith(auth.getAccessToken()); + if (response.status !== 401 || !auth.refreshAccessToken) return response; + + let token: string; + try { + token = await auth.refreshAccessToken(); + } catch { + return response; + } + await response.body?.cancel().catch(() => {}); + return sendWith(token); + }; + + const openStream = async ( + path: string, + init: RequestInit, + { onFrame, onOpen, signal }: RunStreamOptions, + runId?: Uuid, + lastEventId: EventId | null = null, + ): Promise => { + let response: Response; + try { + response = await send(path, { ...init, signal }); + } catch (error) { + if (signal?.aborted) return { reason: "aborted", lastEventId }; + return { reason: "interrupted", cause: "network", error, lastEventId }; + } + + if (!response.ok) + return { + reason: "rejected", + error: await readRunApiError(response), + lastEventId, + }; + const contentType = response.headers.get("Content-Type") ?? ""; + if (!contentType.startsWith(SSE_ACCEPT) || !response.body) { + // 200인데 SSE가 아니면 계약 밖 응답이다(proxy 오류 페이지 등). + const error = await readRunApiError(response); + return { reason: "rejected", error, lastEventId }; + } + + onOpen?.(); + return readRunStream(response.body, { + onFrame, + signal, + runId, + lastEventId, + }); + }; + + const requestJson = async ( + path: string, + init: RequestInit = {}, + ): Promise => { + const headers = new Headers(init.headers); + headers.set("Accept", JSON_ACCEPT); + const response = await send(path, { ...init, headers }); + if (!response.ok) throw await readRunApiError(response); + const body = (await response + .json() + .catch(() => null)) as ApiSuccessResponse | null; + if (body?.isSuccess !== true) + throw new RunApiError({ + code: null, + status: response.status, + rawCode: typeof body?.code === "string" ? body.code : null, + message: typeof body?.message === "string" ? body.message : null, + }); + return body.result; + }; + + /** + * `POST /api/conversations` — 질문을 보내고 SSE를 seq 1(`run.created`)부터 읽는다. + * 같은 전송을 다시 보낼 때는 같은 `request`(같은 requestId)를 넘긴다 — Server가 기존 run을 replay한다. + * 계약에 맞지 않는 request는 보내지 않고 `TypeError`를 던진다(`createConversationRequest`로 만든다). + */ + const streamConversation = ( + request: ConversationRunRequest, + options: RunStreamOptions, + ) => { + const check = validateConversationRequest(request); + if (!check.ok) + throw new TypeError( + `계약에 맞지 않는 요청(${check.code}): ${check.issues + .map((issue) => `${issue.path} ${issue.reason}`) + .join(", ")}`, + ); + return openStream( + "/api/conversations", + { + method: "POST", + headers: { "Content-Type": "application/json", Accept: SSE_ACCEPT }, + body: JSON.stringify(request), + }, + options, + ); + }; + + /** + * `GET /api/ai-runs/{runId}/events` — cursor 다음 seq부터 replay 후 terminal이 아니면 live로 이어 받는다. + * cursor는 같은 run의 frame `id`여야 한다(아니면 `TypeError`). 형식 판정의 최종 기준은 Server(RUN4003·RUN4004)다. + */ + const streamRunEvents = ( + runId: Uuid, + { lastEventId = null, placement = "header" }: RunEventsCursor, + options: RunStreamOptions, + ) => { + if (lastEventId != null && !isEventId(lastEventId)) + throw new TypeError(`event id 형식이 아니다: ${lastEventId}`); + const headers: Record = { Accept: SSE_ACCEPT }; + let query = ""; + if (lastEventId != null && placement === "header") + headers["Last-Event-ID"] = lastEventId; + if (lastEventId != null && placement === "query") + query = `?${new URLSearchParams({ lastEventId })}`; + return openStream( + `/api/ai-runs/${encodeURIComponent(runId)}/events${query}`, + { method: "GET", headers }, + options, + runId, + lastEventId, + ); + }; + + /** `GET /api/ai-runs/{runId}` — 공개 상태와 그 시점 `lastSeq` */ + const getRunStatus = (runId: Uuid, signal?: AbortSignal) => + requestJson(`/api/ai-runs/${encodeURIComponent(runId)}`, { + signal, + }); + + /** `POST /api/ai-runs/{runId}/cancel` — body 없음(`{}`도 RUN4001). 멱등이다. */ + const cancelRun = (runId: Uuid, signal?: AbortSignal) => + requestJson( + `/api/ai-runs/${encodeURIComponent(runId)}/cancel`, + { method: "POST", signal }, + ); + + /** `GET /api/conversations/{noteId}/messages?before&limit` — createdAt 오름차순 한 페이지와 `activeRun` */ + const getConversationMessages = ( + noteId: NoteId, + { before, limit }: ConversationMessagesQuery = {}, + signal?: AbortSignal, + ) => { + const params = new URLSearchParams(); + if (before != null) params.set("before", before); + if (limit != null) params.set("limit", String(limit)); + const query = params.toString() && `?${params}`; + return requestJson( + `/api/conversations/${encodeURIComponent(noteId)}/messages${query}`, + { signal }, + ); + }; + + return { + streamConversation, + streamRunEvents, + getRunStatus, + cancelRun, + getConversationMessages, + }; +}; + +export type RunApiClient = ReturnType; + +/** 앱 기본 client (`tokenUtils`·`refreshAccessToken` 사용) */ +export const runApi = createRunApiClient(); diff --git a/src/features/run/api/run_api_error.test.ts b/src/features/run/api/run_api_error.test.ts new file mode 100644 index 0000000..ad6b2b0 --- /dev/null +++ b/src/features/run/api/run_api_error.test.ts @@ -0,0 +1,109 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { RUN_ERROR_CODES } from "@/shared/contracts/browser_run_v1"; +import { getErrorFixture } from "@/test/contracts/browser_run_fixtures"; +import { + parseRetryAfter, + readRunApiError, + RunApiError, + toRunApiError, +} from "./run_api_error"; +import { jsonResponse } from "./test_streams"; + +describe("toRunApiError — fixture responses/valid 오류 예제", () => { + it.each( + RUN_ERROR_CODES.map((code) => [code, getErrorFixture(code)] as const), + )("%s → HTTP status·code·message", (code, fixture) => { + const error = toRunApiError(fixture.http, fixture.body); + expect(error).toBeInstanceOf(RunApiError); + expect(error.status).toBe(fixture.http); + expect(error.code).toBe(code); + expect(error.message).toBe(fixture.body.message); + }); + + it("RUN4091 → activeRunId", () => { + const fixture = getErrorFixture("RUN4091"); + const error = toRunApiError(fixture.http, fixture.body); + expect(error.detail).toEqual({ + code: "RUN4091", + status: 409, + message: fixture.body.message, + activeRunId: "eeff258f-0aa4-5728-a27a-3c05ac927a57", + }); + }); + + it("RUN4291 → Retry-After header(초)", async () => { + const fixture = getErrorFixture("RUN4291"); + const error = await readRunApiError( + jsonResponse(fixture.http, fixture.body, { "Retry-After": "30" }), + ); + expect(error.detail).toEqual({ + code: "RUN4291", + status: 429, + message: fixture.body.message, + retryAfterSeconds: 30, + }); + }); + + it("RUN4291에 Retry-After가 없으면 null", () => { + const fixture = getErrorFixture("RUN4291"); + expect(toRunApiError(fixture.http, fixture.body).detail).toMatchObject({ + retryAfterSeconds: null, + }); + }); +}); + +describe("toRunApiError — 계약 밖 응답", () => { + it("RUN4091인데 activeRunId가 없으면 계약 밖(code null)", () => { + const error = toRunApiError(409, { + isSuccess: false, + code: "RUN4091", + message: "이 노트에서 진행 중인 답변이 있습니다.", + }); + expect(error.detail).toMatchObject({ code: null, rawCode: "RUN4091" }); + }); + + it("COMMON500 JSON은 rawCode로 남긴다", () => { + const error = toRunApiError(500, { + isSuccess: false, + code: "COMMON500", + message: "서버 오류", + }); + expect(error.detail).toEqual({ + code: null, + status: 500, + rawCode: "COMMON500", + message: "서버 오류", + }); + }); + + it("JSON이 아닌 본문(proxy 502 HTML)", async () => { + const error = await readRunApiError( + new Response("Bad Gateway", { status: 502 }), + ); + expect(error.detail).toEqual({ + code: null, + status: 502, + rawCode: null, + message: null, + }); + expect(error.message).toBe("HTTP 502 계약 밖 응답"); + }); +}); + +describe("parseRetryAfter", () => { + const now = Date.parse("2026-10-20T00:00:00Z"); + + it.each([ + ["0", 0], + ["120", 120], + [" 5 ", 5], + ["Tue, 20 Oct 2026 00:00:30 GMT", 30], + ["Mon, 19 Oct 2026 23:59:00 GMT", 0], + ["-1", null], + ["soon", null], + [null, null], + ])("%s → %s", (value, expected) => { + expect(parseRetryAfter(value, now)).toBe(expected); + }); +}); diff --git a/src/features/run/api/run_api_error.ts b/src/features/run/api/run_api_error.ts new file mode 100644 index 0000000..9846166 --- /dev/null +++ b/src/features/run/api/run_api_error.ts @@ -0,0 +1,135 @@ +import { RUN_ERROR_CODES } from "@/shared/contracts/browser_run_v1"; +import type { RunErrorCode, Uuid } from "@/shared/contracts/browser_run_v1"; +import { isUuid } from "./run_request"; + +/** + * stream 시작 전·조회 API의 오류 응답 (browser-run-v1 `ErrorResponse`). + * `Accept: text/event-stream`이어도 stream 전 오류는 JSON `ApiResponse`다. + * `code`로 구분하고, 계약 밖 응답(COMMON500·proxy HTML 등)은 `code: null`이다. + */ +export type RunApiErrorDetail = + | { + code: "RUN4091"; + status: number; + message: string; + /** 이 Note에서 진행 중인 run. `GET /api/ai-runs/{activeRunId}/events`로 이어 볼 수 있다. */ + activeRunId: Uuid; + } + | { + code: "RUN4291"; + status: number; + message: string; + /** `Retry-After` header(초). 없거나 해석할 수 없으면 null */ + retryAfterSeconds: number | null; + } + | { + code: Exclude; + status: number; + message: string; + } + | { + code: null; + status: number; + /** body에 문자열 code가 있었으면 그 값(예: `COMMON500`) */ + rawCode: string | null; + message: string | null; + }; + +export class RunApiError extends Error { + readonly detail: RunApiErrorDetail; + + constructor(detail: RunApiErrorDetail) { + super(detail.message ?? `HTTP ${detail.status} 계약 밖 응답`); + this.name = "RunApiError"; + this.detail = detail; + } + + get status() { + return this.detail.status; + } + + get code() { + return this.detail.code; + } +} + +const isRunErrorCode = (value: unknown): value is RunErrorCode => + (RUN_ERROR_CODES as readonly unknown[]).includes(value); + +/** 실패 응답(`!response.ok`)의 body·header를 읽어 `RunApiError`로 만든다. body는 소비된다. */ +export const readRunApiError = async ( + response: Response, + now: number = Date.now(), +): Promise => { + let body: unknown = null; + try { + body = JSON.parse(await response.text()); + } catch { + // JSON이 아닌 오류 본문(proxy 502 HTML 등)은 계약 밖 응답으로 둔다. + } + return toRunApiError( + response.status, + body, + response.headers.get("Retry-After"), + now, + ); +}; + +/** status·JSON body·`Retry-After`로 오류를 해석한다. */ +export const toRunApiError = ( + status: number, + body: unknown, + retryAfter: string | null = null, + now: number = Date.now(), +): RunApiError => { + const envelope = + typeof body === "object" && body !== null + ? (body as Record) + : {}; + const code = envelope.code; + const message = + typeof envelope.message === "string" ? envelope.message : null; + + if (envelope.isSuccess === false && isRunErrorCode(code) && message) { + if (code === "RUN4091") { + const result = envelope.result as { activeRunId?: unknown } | undefined; + if (isUuid(result?.activeRunId)) + return new RunApiError({ + code, + status, + message, + activeRunId: result.activeRunId, + }); + } else if (code === "RUN4291") { + return new RunApiError({ + code, + status, + message, + retryAfterSeconds: parseRetryAfter(retryAfter, now), + }); + } else { + return new RunApiError({ code, status, message }); + } + } + + return new RunApiError({ + code: null, + status, + rawCode: typeof code === "string" ? code : null, + message, + }); +}; + +/** `Retry-After`: delta-seconds 또는 HTTP-date → 남은 초(0 이상 정수) */ +export const parseRetryAfter = ( + value: string | null, + now: number = Date.now(), +): number | null => { + if (value == null) return null; + const trimmed = value.trim(); + if (/^\d+$/.test(trimmed)) return Number(trimmed); + // HTTP-date는 요일·월 이름이 있다. 숫자만 있는 다른 값("-1" 등)을 Date.parse가 연도로 읽지 않게 한다. + const date = /[A-Za-z]/.test(trimmed) ? Date.parse(trimmed) : NaN; + if (Number.isNaN(date)) return null; + return Math.max(0, Math.ceil((date - now) / 1000)); +}; diff --git a/src/features/run/api/run_request.test.ts b/src/features/run/api/run_request.test.ts new file mode 100644 index 0000000..e0e4b9d --- /dev/null +++ b/src/features/run/api/run_request.test.ts @@ -0,0 +1,265 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { + listInvalidRequestFixtures, + listRequestFixtures, + readContractJson, +} from "@/test/contracts/browser_run_fixtures"; +import { + countCodePoints, + createConversationRequest, + createRequestId, + stripPythonWhitespace, + validateConversationRequest, +} from "./run_request"; + +const LOWERCASE_UUID = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +describe("validateConversationRequest — fixture requests", () => { + it.each( + listRequestFixtures().map((fixture) => [fixture.name, fixture] as const), + )("valid %s → 통과", (_name, fixture) => { + expect(validateConversationRequest(fixture.request)).toEqual({ + ok: true, + request: fixture.request, + }); + }); + + it.each( + listInvalidRequestFixtures().map( + (fixture) => [fixture.name, fixture] as const, + ), + )("invalid %s → 거부(기대 code)", (_name, fixture) => { + const result = validateConversationRequest(fixture.request); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(fixture.expected.code); + expect(result.issues.length).toBeGreaterThan(0); + } + }); + + it("valid 10개·invalid 43개를 모두 검사한다", () => { + expect(listRequestFixtures()).toHaveLength(10); + expect(listInvalidRequestFixtures()).toHaveLength(43); + }); + + it("구조 위반과 video가 함께 있으면 RUN4001이 먼저다", () => { + const result = validateConversationRequest({ + requestId: "7f460a7b-bf54-570e-a3af-b63a26435627", + text: "풀어줘", + requestedOutputs: ["video"], + locale: "ko", + }); + expect(result).toMatchObject({ ok: false, code: "RUN4001" }); + }); +}); + +describe("strip·code point 경계", () => { + // CATALOG의 Python str.strip() 집합 + const stripSet = [ + "\t", + "\n", + "\v", + "\f", + "\r", + "\u001c", + "\u001d", + "\u001e", + "\u001f", + " ", + "\u0085", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + "
", + "
", + " ", + " ", + " ", + ]; + + it.each(stripSet.map((char) => [char.codePointAt(0)!.toString(16), char]))( + "U+%s는 양끝에서 지운다", + (_hex, char) => { + expect(stripPythonWhitespace(`${char}${char}가${char}나${char}`)).toBe( + `가${char}나`, + ); + }, + ); + + it("JS trim이 지우는 U+FEFF는 Python strip 집합이 아니라 남긴다", () => { + expect("가".trim()).toBe("가"); + expect(stripPythonWhitespace("가")).toBe("가"); + expect( + validateConversationRequest({ + requestId: "7f460a7b-bf54-570e-a3af-b63a26435627", + text: "", + }).ok, + ).toBe(true); + }); + + it("strip 집합 공백만 있는 text는 거부한다", () => { + expect( + validateConversationRequest({ + requestId: "7f460a7b-bf54-570e-a3af-b63a26435627", + text: stripSet.join(""), + }), + ).toMatchObject({ ok: false, code: "RUN4001" }); + }); + + const requestWith = (text: string, latex?: string) => ({ + requestId: "7f460a7b-bf54-570e-a3af-b63a26435627", + text, + ...(latex !== undefined && { latex }), + }); + + it("astral 문자는 code point 하나로 센다(JS .length 아님)", () => { + const astral = "\u{1d465}"; + const atLimit = astral.repeat(10000); + expect(atLimit.length).toBe(20000); + expect(countCodePoints(atLimit)).toBe(10000); + expect(validateConversationRequest(requestWith(atLimit)).ok).toBe(true); + expect( + validateConversationRequest(requestWith(atLimit + astral)), + ).toMatchObject({ + ok: false, + code: "RUN4001", + }); + }); + + it("상한은 strip 뒤 길이로 본다 — text·latex 각각", () => { + const atLimit = "가".repeat(10000); + const blanks = "   "; + expect( + validateConversationRequest(requestWith(`${blanks}${atLimit}${blanks}`)) + .ok, + ).toBe(true); + expect(validateConversationRequest(requestWith(`${atLimit}가`)).ok).toBe( + false, + ); + expect( + validateConversationRequest( + requestWith("a", `${blanks}${atLimit}${blanks}`), + ).ok, + ).toBe(true); + expect( + validateConversationRequest(requestWith("a", `${atLimit}가`)).ok, + ).toBe(false); + expect(validateConversationRequest(requestWith("a", blanks)).ok).toBe( + false, + ); + }); +}); + +describe("createConversationRequest", () => { + it("새 질문마다 새 소문자 UUID requestId를 만든다", () => { + const first = createConversationRequest({ text: "풀어줘" }); + const second = createConversationRequest({ text: "풀어줘" }); + expect(first.ok && second.ok).toBe(true); + if (first.ok && second.ok) { + expect(first.request.requestId).toMatch(LOWERCASE_UUID); + expect(second.request.requestId).toMatch(LOWERCASE_UUID); + expect(first.request.requestId).not.toBe(second.request.requestId); + } + }); + + it("첫 질문은 noteId 키 없이 만든다", () => { + for (const noteId of [undefined, null]) { + const result = createConversationRequest({ noteId, text: "풀어줘" }); + expect(result.ok).toBe(true); + if (result.ok) + expect(Object.keys(result.request)).toEqual(["requestId", "text"]); + } + }); + + it("fixture first-no-note와 같은 body를 만든다", () => { + const fixture = readContractJson<{ request: Record }>( + "requests/valid/first-no-note.json", + ).request; + const result = createConversationRequest( + { text: fixture.text as string }, + () => fixture.requestId as string, + ); + expect(result).toEqual({ ok: true, request: fixture }); + }); + + it("fixture existing-note-full의 모든 선택 필드를 그대로 싣는다", () => { + const fixture = listRequestFixtures().find( + (item) => item.name === "existing-note-full", + )!.request; + const { requestId, ...input } = fixture; + expect(createConversationRequest(input, () => requestId)).toEqual({ + ok: true, + request: fixture, + }); + }); + + it("text·latex를 strip하고 strip 뒤 빈 latex는 뺀다", () => { + const result = createConversationRequest({ + noteId: "123", + text: "  풀어줘\n", + latex: "  ", + }); + expect(result).toMatchObject({ + ok: true, + request: { noteId: "123", text: "풀어줘" }, + }); + if (result.ok) expect("latex" in result.request).toBe(false); + }); + + it("검증에 실패하면 이유를 돌려준다", () => { + expect(createConversationRequest({ text: " " })).toMatchObject({ + ok: false, + code: "RUN4001", + issues: [{ path: "text" }], + }); + expect( + createConversationRequest({ text: "a", requestedOutputs: ["video"] }), + ).toMatchObject({ ok: false, code: "RUN4002" }); + }); + + it("같은 전송 재시도는 만든 요청을 그대로 쓴다(입력 배열을 고쳐도 영향 없음)", () => { + const tasks: ("solve" | "check")[] = ["solve"]; + const result = createConversationRequest({ + text: "a", + requestedTasks: tasks, + }); + tasks.push("check"); + expect(result.ok && result.request.requestedTasks).toEqual(["solve"]); + }); +}); + +describe("createRequestId", () => { + it("crypto.randomUUID가 없어도 소문자 UUID v4를 만든다", () => { + const original = crypto.randomUUID; + Object.defineProperty(crypto, "randomUUID", { + value: undefined, + configurable: true, + }); + try { + const ids = Array.from({ length: 50 }, createRequestId); + for (const id of ids) { + expect(id).toMatch(LOWERCASE_UUID); + expect(id[14]).toBe("4"); + expect("89ab").toContain(id[19]); + } + expect(new Set(ids).size).toBe(ids.length); + } finally { + Object.defineProperty(crypto, "randomUUID", { + value: original, + configurable: true, + }); + } + }); +}); diff --git a/src/features/run/api/run_request.ts b/src/features/run/api/run_request.ts new file mode 100644 index 0000000..e1bdb8d --- /dev/null +++ b/src/features/run/api/run_request.ts @@ -0,0 +1,337 @@ +import { + BROWSER_RUN_LIMITS, + REQUESTED_OUTPUTS, + REQUESTED_TASKS, +} from "@/shared/contracts/browser_run_v1"; +import type { + ConversationRunRequest, + NoteId, + RequestedOutput, + RequestedTask, + Uuid, + ViewerFocus, +} from "@/shared/contracts/browser_run_v1"; + +/** + * `POST /api/conversations` 요청 생성·검증 (browser-run-v1, closed body). + * + * - 새 질문마다 `createConversationRequest`로 새 `requestId`를 만든다. 같은 전송을 다시 보낼 때는 + * 만든 요청 객체를 그대로 다시 보낸다(같은 requestId·같은 의미 → Server가 기존 run을 replay). + * - `text`·`latex` 길이는 Python `str.strip()` 집합으로 strip한 뒤 code point로 센다(JS `.length` 금지). + * - 판정 기준은 fixture `requests/valid`·`requests/invalid`다. 구조 위반은 RUN4001, `video` output만 RUN4002다. + */ + +// --------------------------------------------------------------------------- +// 생성 +// --------------------------------------------------------------------------- + +/** 사용자가 보낸 한 질문. `noteId`가 없으면 첫 질문이다(Server가 Note를 만든다). */ +export interface ConversationQuestionInput { + noteId?: NoteId | null; + text: string; + latex?: string | null; + requestedTasks?: RequestedTask[]; + requestedOutputs?: RequestedOutput[]; + turnAttachmentIds?: Uuid[]; + viewerFocus?: ViewerFocus | null; +} + +/** + * 새 질문의 요청 body를 만들고 검증한다. + * text·latex는 strip해서 보내고(Server도 strip 후 판정한다), strip 후 빈 latex와 없는 noteId는 키를 빼서 보낸다. + */ +export const createConversationRequest = ( + input: ConversationQuestionInput, + generateRequestId: () => Uuid = createRequestId, +): ConversationRequestCheck => { + const latex = input.latex == null ? "" : stripPythonWhitespace(input.latex); + const request: ConversationRunRequest = { + requestId: generateRequestId(), + ...(input.noteId != null && { noteId: input.noteId }), + text: stripPythonWhitespace(input.text), + ...(latex && { latex }), + ...(input.requestedTasks && { requestedTasks: [...input.requestedTasks] }), + ...(input.requestedOutputs && { + requestedOutputs: [...input.requestedOutputs], + }), + ...(input.turnAttachmentIds && { + turnAttachmentIds: [...input.turnAttachmentIds], + }), + ...(input.viewerFocus && { + viewerFocus: copyViewerFocus(input.viewerFocus), + }), + }; + return validateConversationRequest(request); +}; + +/** 소문자 canonical UUID v4. `crypto.randomUUID`가 없는 비보안 context(http LAN 등)도 지원한다. */ +export const createRequestId = (): Uuid => { + if (typeof crypto.randomUUID === "function") return crypto.randomUUID(); + const bytes = crypto.getRandomValues(new Uint8Array(16)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")); + return [ + hex.slice(0, 4), + hex.slice(4, 6), + hex.slice(6, 8), + hex.slice(8, 10), + hex.slice(10, 16), + ] + .map((part) => part.join("")) + .join("-"); +}; + +const copyViewerFocus = ({ + noteAssetId, + page, + selection, +}: ViewerFocus): ViewerFocus => ({ + noteAssetId, + page, + ...(selection && { selection: { ...selection } }), +}); + +// --------------------------------------------------------------------------- +// strip·길이 +// --------------------------------------------------------------------------- + +/** + * Python `str.strip()`이 지우는 공백 집합(internal-run-v1과 같음). + * Java `String.strip()`·JS `String.prototype.trim()`과 다르다(U+00A0·U+0085·U+202F·U+001C~U+001F 등). + */ +const STRIP_CODE_POINTS = new Set([ + 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, 0x85, 0xa0, + 0x1680, 0x2000, 0x2001, 0x2002, 0x2003, 0x2004, 0x2005, 0x2006, 0x2007, + 0x2008, 0x2009, 0x200a, 0x2028, 0x2029, 0x202f, 0x205f, 0x3000, +]); + +// strip 집합은 모두 BMP라 UTF-16 단위로 양끝을 보면 된다(surrogate는 strip 대상이 아니다). +export const stripPythonWhitespace = (value: string) => { + let start = 0; + let end = value.length; + while (start < end && STRIP_CODE_POINTS.has(value.charCodeAt(start))) start++; + while (end > start && STRIP_CODE_POINTS.has(value.charCodeAt(end - 1))) end--; + return value.slice(start, end); +}; + +/** code point 수 (astral 문자 하나 = 1) */ +export const countCodePoints = (value: string) => [...value].length; + +// --------------------------------------------------------------------------- +// 검증 +// --------------------------------------------------------------------------- + +export type RequestValidationCode = "RUN4001" | "RUN4002"; + +export interface RequestValidationIssue { + /** 예: `text`, `viewerFocus.selection` */ + path: string; + code: RequestValidationCode; + reason: string; +} + +export type ConversationRequestCheck = + | { ok: true; request: ConversationRunRequest } + | { + ok: false; + /** Server가 돌려줄 code. 구조 위반(RUN4001)이 하나라도 있으면 RUN4001이다. */ + code: RequestValidationCode; + issues: RequestValidationIssue[]; + }; + +const REQUEST_KEYS = new Set([ + "requestId", + "noteId", + "text", + "latex", + "requestedTasks", + "requestedOutputs", + "turnAttachmentIds", + "viewerFocus", +]); +const VIEWER_FOCUS_KEYS = new Set(["noteAssetId", "page", "selection"]); +const SELECTION_KEYS = ["left", "top", "right", "bottom"] as const; + +// schema 패턴을 그대로 옮겼다. 끝은 `$` 대신 절대 끝 `(?![\s\S])`로 고정한다(CATALOG 형식 판정). +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?![\s\S])/; +const NOTE_ID_PATTERN = /^[1-9][0-9]{0,18}(?![\s\S])/; +const NOTE_ID_MAX = 9223372036854775807n; +const OPAQUE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}(?![\s\S])/; +/** + * schema의 page는 상한이 없지만 Server는 Java int로 받는다. 판정이 갈리는 값은 보내지 않는다(1.75 후속 메모). + */ +const VIEWER_PAGE_MAX = 2147483647; + +export const isUuid = (value: unknown): value is Uuid => + typeof value === "string" && UUID_PATTERN.test(value); + +export const isNoteId = (value: unknown): value is NoteId => + typeof value === "string" && + NOTE_ID_PATTERN.test(value) && + BigInt(value) <= NOTE_ID_MAX; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** body를 보내기 전에 browser-run-v1 요청 계약(schema + 계약 검사)으로 검사한다. */ +export const validateConversationRequest = ( + value: unknown, +): ConversationRequestCheck => { + const issues: RequestValidationIssue[] = []; + const reject = ( + path: string, + reason: string, + code: RequestValidationCode = "RUN4001", + ) => issues.push({ path, code, reason }); + + if (!isRecord(value)) { + reject("", "요청 body는 객체여야 한다"); + return { ok: false, code: "RUN4001", issues }; + } + + for (const key of Object.keys(value)) { + if (!REQUEST_KEYS.has(key)) reject(key, "요청에 없는 필드(closed body)"); + } + + if (!isUuid(value.requestId)) + reject("requestId", "소문자 canonical UUID가 아니다"); + + if ("noteId" in value && !isNoteId(value.noteId)) + reject("noteId", "선행 0 없는 10진 문자열 1..2^63-1이 아니다"); + + checkBoundedText(value.text, "text", reject); + if (value.latex != null) checkBoundedText(value.latex, "latex", reject); + + checkEnumArray( + value.requestedTasks, + "requestedTasks", + REQUESTED_TASKS, + reject, + ); + checkEnumArray( + value.requestedOutputs, + "requestedOutputs", + REQUESTED_OUTPUTS, + reject, + ); + if ( + Array.isArray(value.requestedOutputs) && + value.requestedOutputs.includes("video") + ) + reject("requestedOutputs", "video는 Phase 1 미지원", "RUN4002"); + + if ("turnAttachmentIds" in value) { + const ids = value.turnAttachmentIds; + if (!Array.isArray(ids)) reject("turnAttachmentIds", "배열이 아니다"); + else { + if (ids.length > BROWSER_RUN_LIMITS.turnAttachmentIdsWireMax) + reject("turnAttachmentIds", "wire 상한 10개 초과"); + if (!ids.every(isUuid)) + reject("turnAttachmentIds", "항목이 소문자 canonical UUID가 아니다"); + if (new Set(ids).size !== ids.length) + reject("turnAttachmentIds", "중복 항목"); + } + } + + if (value.viewerFocus != null) checkViewerFocus(value.viewerFocus, reject); + + if (issues.length > 0) { + const code = issues.some((issue) => issue.code === "RUN4001") + ? "RUN4001" + : "RUN4002"; + return { ok: false, code, issues }; + } + return { ok: true, request: value as unknown as ConversationRunRequest }; +}; + +type Reject = ( + path: string, + reason: string, + code?: RequestValidationCode, +) => void; + +const checkBoundedText = ( + value: unknown, + path: "text" | "latex", + reject: Reject, +) => { + if (typeof value !== "string") { + reject(path, "문자열이 아니다"); + return; + } + const length = countCodePoints(stripPythonWhitespace(value)); + const max = + path === "text" + ? BROWSER_RUN_LIMITS.textMaxCodePoints + : BROWSER_RUN_LIMITS.latexMaxCodePoints; + if (length === 0) reject(path, "strip 후 비어 있다"); + else if (length > max) reject(path, `strip 후 ${max} code point 초과`); +}; + +const checkEnumArray = ( + value: unknown, + path: string, + allowed: readonly string[], + reject: Reject, +) => { + if (value === undefined) return; + if (!Array.isArray(value)) { + reject(path, "배열이 아니다"); + return; + } + if (!value.every((item) => allowed.includes(item))) + reject(path, "알 수 없는 값"); + if (new Set(value).size !== value.length) reject(path, "중복 항목"); +}; + +const checkViewerFocus = (value: unknown, reject: Reject) => { + if (!isRecord(value)) { + reject("viewerFocus", "객체가 아니다"); + return; + } + for (const key of Object.keys(value)) { + if (!VIEWER_FOCUS_KEYS.has(key)) + reject(`viewerFocus.${key}`, "요청에 없는 필드(closed body)"); + } + if ( + typeof value.noteAssetId !== "string" || + !OPAQUE_ID_PATTERN.test(value.noteAssetId) + ) + reject("viewerFocus.noteAssetId", "opaque ID 형식이 아니다"); + if ( + typeof value.page !== "number" || + !Number.isInteger(value.page) || + value.page < 1 || + value.page > VIEWER_PAGE_MAX + ) + reject("viewerFocus.page", "1-based 정수 page가 아니다"); + if (value.selection != null) checkSelection(value.selection, reject); +}; + +const checkSelection = (value: unknown, reject: Reject) => { + const path = "viewerFocus.selection"; + if (!isRecord(value)) { + reject(path, "객체가 아니다"); + return; + } + for (const key of Object.keys(value)) { + if (!(SELECTION_KEYS as readonly string[]).includes(key)) + reject(`${path}.${key}`, "요청에 없는 필드(closed body)"); + } + const { left, top, right, bottom } = value; + if ( + !SELECTION_KEYS.every( + (key) => typeof value[key] === "number" && Number.isFinite(value[key]), + ) + ) { + reject(path, "좌표가 숫자가 아니다"); + return; + } + const [l, t, r, b] = [left, top, right, bottom] as number[]; + // page-selection-v1: left·top ∈ [0, 1), right·bottom ∈ (0, 1] + if (l < 0 || l >= 1 || t < 0 || t >= 1 || r <= 0 || r > 1 || b <= 0 || b > 1) + reject(path, "좌표가 [0, 1] 범위 밖이다"); + if (l >= r || t >= b) reject(path, "left = {}, +) => { + const frames: BrowserFrame[] = []; + const testStream = createTestStream(chunks, streamOptions); + const end = await readRunStream(testStream.stream, { + ...readOptions, + onFrame: (frame) => frames.push(frame), + }); + return { frames, end, isCanceled: testStream.isCanceled }; +}; + +const lastIdOf = (frames: BrowserFrame[]) => { + const last = frames[frames.length - 1]; + return toEventId(last.runId, last.seq); +}; + +/** fixture SSE를 frame 블록 단위로 나눈다(각 블록은 빈 줄로 끝난다). */ +const splitBlocks = (sse: string) => + sse + .split("\n\n") + .filter(Boolean) + .map((block) => `${block}\n\n`); + +const streamFixtures = listStreamFixtures(); + +describe("readRunStream — fixture streams/valid", () => { + it("fixture는 10개다", () => { + expect(streamFixtures).toHaveLength(10); + }); + + it.each(streamFixtures.map((fixture) => [fixture.name, fixture] as const))( + "%s: SSE 바이트 → 같은 frame 목록, terminal 정상 종료", + async (_name, fixture) => { + const { frames, end } = await readAll([encodeSse(fixture.sse)]); + + expect(frames).toEqual(fixture.frames); + expect(end).toEqual({ + reason: "terminal", + terminalFrame: fixture.frames[fixture.frames.length - 1], + lastEventId: lastIdOf(fixture.frames), + }); + }, + ); + + it.each(streamFixtures.map((fixture) => [fixture.name, fixture] as const))( + "%s: 1바이트·임의 청크 분할에도 같은 frame 목록", + async (_name, fixture) => { + const bytes = encodeSse(fixture.sse); + for (const chunks of [ + splitBytes(bytes, 1), + splitBytesRandomly(bytes, 7), + splitBytesRandomly(bytes, 2026, 5), + ]) { + const { frames, end } = await readAll(chunks); + expect(frames).toEqual(fixture.frames); + expect(end.reason).toBe("terminal"); + } + }, + ); + + it("모든 위치에서 두 조각으로 나눠도 같다 (UTF-8 다바이트 문자·CRLF 경계 포함)", async () => { + const fixture = getStreamFixture("canceled-pending"); + const variants = [fixture.sse, fixture.sse.replaceAll("\n", "\r\n")]; + for (const sse of variants) { + const bytes = encodeSse(sse); + for (let cut = 1; cut < bytes.length; cut++) { + const { frames } = await readAll([ + bytes.slice(0, cut), + bytes.slice(cut), + ]); + expect(frames, `cut ${cut}`).toEqual(fixture.frames); + } + } + }); + + it.each([ + ["CRLF", "\r\n"], + ["CR", "\r"], + ])("줄 끝 %s도 LF와 같은 frame", async (_name, newline) => { + const fixture = getStreamFixture("substantive-completed"); + const sse = fixture.sse.replaceAll("\n", newline); + for (const chunks of [[encodeSse(sse)], splitBytes(encodeSse(sse), 1)]) { + const { frames, end } = await readAll(chunks); + expect(frames).toEqual(fixture.frames); + expect(end.reason).toBe("terminal"); + } + }); +}); + +describe("readRunStream — heartbeat", () => { + const fixture = getStreamFixture("substantive-completed"); + const { comment, invalidCases } = getHeartbeatFixture(); + + it("heartbeat 원문은 계약 사본과 같다", () => { + expect(comment).toBe(HEARTBEAT_COMMENT); + }); + + it("frame 사이·앞의 heartbeat comment는 frame·cursor가 아니다", async () => { + const blocks = splitBlocks(fixture.sse); + const withHeartbeats = [ + comment, + ...blocks.flatMap((block) => [block, comment, comment]), + ].join(""); + const { frames, end } = await readAll( + splitBytesRandomly(encodeSse(withHeartbeats), 11), + ); + expect(frames).toEqual(fixture.frames); + expect(end).toMatchObject({ + reason: "terminal", + lastEventId: lastIdOf(fixture.frames), + }); + }); + + it("heartbeat만 오다 끊기면 cursor는 연결 때 보낸 값 그대로다", async () => { + const cursor = toEventId(fixture.runId, 3); + const { frames, end } = await readAll( + [comment, comment], + {}, + { lastEventId: cursor, runId: fixture.runId }, + ); + expect(frames).toEqual([]); + expect(end).toEqual({ + reason: "interrupted", + cause: "eof", + lastEventId: cursor, + }); + }); + + it("heartbeat-with-id: data 없는 블록의 id는 cursor를 바꾸지 않는다", async () => { + const heartbeatWithId = invalidCases.find( + (testCase) => testCase.name === "heartbeat-with-id", + )!; + const [first, second] = splitBlocks(fixture.sse); + const { frames, end } = await readAll([first, heartbeatWithId.sse, second]); + expect(frames).toEqual(fixture.frames.slice(0, 2)); + expect(end).toMatchObject({ + reason: "interrupted", + cause: "eof", + lastEventId: lastIdOf(fixture.frames.slice(0, 2)), + }); + }); + + it.each(["heartbeat-with-data", "heartbeat-as-event"])( + "%s: frame이 아닌 data 블록은 protocol 오류로 연결을 끝낸다", + async (name) => { + const invalid = invalidCases.find((testCase) => testCase.name === name)!; + const [first, ...rest] = splitBlocks(fixture.sse); + const { frames, end, isCanceled } = await readAll([ + first, + invalid.sse, + ...rest, + ]); + expect(frames).toEqual(fixture.frames.slice(0, 1)); + expect(end).toMatchObject({ + reason: "interrupted", + cause: "protocol", + lastEventId: lastIdOf(fixture.frames.slice(0, 1)), + }); + if (end.reason === "interrupted") + expect(end.error).toBeInstanceOf(SseProtocolError); + expect(isCanceled()).toBe(true); + }, + ); +}); + +describe("readRunStream — 종료 사유", () => { + const fixture = getStreamFixture("canceled-running"); + const blocks = splitBlocks(fixture.sse); + const nonTerminalBlocks = blocks.slice(0, -1); + const nonTerminalFrames = fixture.frames.slice(0, -1); + + it("terminal 없이 서버가 닫으면 interrupted(eof)와 마지막 frame cursor", async () => { + const { frames, end } = await readAll(nonTerminalBlocks); + expect(frames).toEqual(nonTerminalFrames); + expect(end).toEqual({ + reason: "interrupted", + cause: "eof", + lastEventId: lastIdOf(nonTerminalFrames), + }); + }); + + it("빈 줄 없이 잘린 마지막 블록은 frame으로 내지 않는다", async () => { + const terminalBlock = blocks[blocks.length - 1]; + for (const cut of [1, terminalBlock.length - 2, terminalBlock.length - 1]) { + const { frames, end } = await readAll([ + ...nonTerminalBlocks, + terminalBlock.slice(0, cut), + ]); + expect(frames).toEqual(nonTerminalFrames); + expect(end).toMatchObject({ reason: "interrupted", cause: "eof" }); + } + }); + + it("읽는 중 네트워크 오류는 정상 종료가 아니라 interrupted(network)", async () => { + const { frames, end } = await readAll(nonTerminalBlocks, { end: "error" }); + expect(frames).toEqual(nonTerminalFrames); + expect(end).toMatchObject({ + reason: "interrupted", + cause: "network", + lastEventId: lastIdOf(nonTerminalFrames), + }); + if (end.reason === "interrupted") + expect(end.error).toBeInstanceOf(TypeError); + }); + + it("terminal 뒤 네트워크 오류가 와도 terminal 정상 종료다", async () => { + const { end } = await readAll(blocks, { end: "error" }); + expect(end.reason).toBe("terminal"); + }); + + it("사용자 abort는 aborted이고 abort 뒤 frame을 내지 않는다", async () => { + const controller = new AbortController(); + const frames: BrowserFrame[] = []; + const testStream = createTestStream(nonTerminalBlocks, { end: "open" }); + const endPromise = readRunStream(testStream.stream, { + signal: controller.signal, + onFrame: (frame) => { + frames.push(frame); + if (frame.seq === 2) controller.abort(); + }, + }); + const end = await endPromise; + expect(frames.map((frame) => frame.seq)).toEqual([1, 2]); + expect(end).toEqual({ + reason: "aborted", + lastEventId: lastIdOf(nonTerminalFrames.slice(0, 2)), + }); + expect(testStream.isCanceled()).toBe(true); + }); + + it("열린 연결을 기다리는 중 abort해도 aborted", async () => { + const controller = new AbortController(); + const testStream = createTestStream(nonTerminalBlocks, { end: "open" }); + const onFrame = vi.fn(); + const endPromise = readRunStream(testStream.stream, { + signal: controller.signal, + onFrame, + }); + await vi.waitFor(() => + expect(onFrame).toHaveBeenCalledTimes(nonTerminalFrames.length), + ); + controller.abort(); + expect(await endPromise).toEqual({ + reason: "aborted", + lastEventId: lastIdOf(nonTerminalFrames), + }); + expect(testStream.isCanceled()).toBe(true); + }); + + it("이미 abort된 signal이면 읽지 않고 aborted", async () => { + const controller = new AbortController(); + controller.abort(); + const { frames, end } = await readAll( + blocks, + {}, + { + signal: controller.signal, + }, + ); + expect(frames).toEqual([]); + expect(end).toEqual({ reason: "aborted", lastEventId: null }); + }); + + it("terminal 뒤 바이트는 읽지 않고 연결을 닫는다", async () => { + const extra = blocks[1]; + const { frames, end, isCanceled } = await readAll([...blocks, extra], { + end: "open", + }); + expect(frames).toEqual(fixture.frames); + expect(end.reason).toBe("terminal"); + expect(isCanceled()).toBe(true); + }); + + it("onFrame이 던지면 연결을 닫고 그 오류를 던진다", async () => { + const testStream = createTestStream(blocks, { end: "open" }); + const failure = new Error("reducer 실패"); + await expect( + readRunStream(testStream.stream, { + onFrame: () => { + throw failure; + }, + }), + ).rejects.toBe(failure); + expect(testStream.isCanceled()).toBe(true); + }); +}); + +describe("readRunStream — protocol 오류", () => { + const fixture = getStreamFixture("canceled-pending"); + const [firstBlock, secondBlock] = splitBlocks(fixture.sse); + const firstId = lastIdOf(fixture.frames.slice(0, 1)); + + it.each([ + [ + "JSON이 아닌 data", + `event: run.canceled\nid: ${fixture.runId}:2\ndata: {oops\n\n`, + ], + [ + "event와 type 불일치", + secondBlock.replace("event: run.canceled", "event: run.failed"), + ], + [ + "id와 runId:seq 불일치", + secondBlock.replace(`${fixture.runId}:2`, `${fixture.runId}:3`), + ], + ["id 없음", secondBlock.replace(/^id: .*\n/m, "")], + [ + "알 수 없는 type", + `event: run.paused\nid: ${fixture.runId}:2\ndata: {"type":"run.paused","runId":"${fixture.runId}","seq":2}\n\n`, + ], + ])("%s → interrupted(protocol), 앞 frame cursor 유지", async (_name, bad) => { + const { frames, end } = await readAll([firstBlock, bad, secondBlock]); + expect(frames).toEqual(fixture.frames.slice(0, 1)); + expect(end).toMatchObject({ + reason: "interrupted", + cause: "protocol", + lastEventId: firstId, + }); + }); + + it("GET 재접속에서 다른 run의 frame은 protocol 오류", async () => { + const other = getStreamFixture("substantive-completed"); + const { frames, end } = await readAll( + [other.sse], + {}, + { + runId: fixture.runId, + }, + ); + expect(frames).toEqual([]); + expect(end).toMatchObject({ reason: "interrupted", cause: "protocol" }); + }); + + it("POST는 첫 frame의 runId로 고정한다", async () => { + const other = getStreamFixture("substantive-completed"); + const { frames, end } = await readAll([ + firstBlock, + splitBlocks(other.sse)[1], + ]); + expect(frames).toEqual(fixture.frames.slice(0, 1)); + expect(end).toMatchObject({ reason: "interrupted", cause: "protocol" }); + }); +}); + +describe("createSseParser", () => { + const parse = (...parts: string[]) => { + const messages: SseMessage[] = []; + const parser = createSseParser((message) => messages.push(message)); + parts.forEach(parser.feed); + return messages; + }; + + it("여러 data 줄은 \\n으로 잇고, 콜론 뒤 공백은 한 칸만 뗀다", () => { + expect(parse("event: a\nid: 1\ndata: x\ndata:y\n\n")).toEqual([ + { event: "a", id: "1", data: " x\ny" }, + ]); + }); + + it("event가 없으면 message, id는 블록마다 새로 본다", () => { + expect(parse("id: 1\ndata: a\n\ndata: b\n\n")).toEqual([ + { event: "message", id: "1", data: "a" }, + { event: "message", id: null, data: "b" }, + ]); + }); + + it("comment·retry·data 없는 블록은 메시지가 아니다", () => { + expect(parse(": heartbeat\n\nretry: 10\n\nevent: x\nid: 2\n\n")).toEqual( + [], + ); + }); + + it("CR로 끝난 조각 뒤 LF로 시작하는 조각은 CRLF 하나다", () => { + expect(parse("data: a\r", "\n\r", "\n")).toEqual([ + { event: "message", id: null, data: "a" }, + ]); + expect(parse("data: a\r", "", "\n\r", "", "\n")).toEqual([ + { event: "message", id: null, data: "a" }, + ]); + }); +}); diff --git a/src/features/run/api/sse_reader.ts b/src/features/run/api/sse_reader.ts new file mode 100644 index 0000000..d9b8bf3 --- /dev/null +++ b/src/features/run/api/sse_reader.ts @@ -0,0 +1,264 @@ +import { + BROWSER_FRAME_TYPES, + isTerminalFrame, + toEventId, +} from "@/shared/contracts/browser_run_v1"; +import type { + BrowserFrame, + EventId, + TerminalFrame, + Uuid, +} from "@/shared/contracts/browser_run_v1"; +import type { RunApiError } from "./run_api_error"; + +/** + * fetch 기반 browser-run SSE reader. + * + * POST SSE는 body와 `Authorization`이 필요해 `EventSource`를 쓰지 않는다. 한 번의 연결만 읽고, + * 재접속·backoff는 호출한 쪽(1.93)이 `RunStreamEnd`를 보고 정한다. + * + * - 줄 끝은 CR·LF·CRLF 모두 허용하고 청크 경계에서 갈린 줄·CRLF도 이어 붙인다(WHATWG SSE). + * - comment(`: heartbeat`)와 data 없는 블록은 frame·cursor가 아니다. + * - 끝 빈 줄 없이 끊긴 마지막 블록은 잘린 것이라 버린다. + */ + +// --------------------------------------------------------------------------- +// 종료 사유 +// --------------------------------------------------------------------------- + +/** + * 한 연결이 끝난 사유. `lastEventId`는 다음 재접속에 쓸 cursor다 — 이 연결에서 마지막으로 + * 처리한 frame의 `id`, frame이 없으면 연결할 때 보낸 cursor(없으면 null). heartbeat는 cursor가 아니다. + */ +export type RunStreamEnd = + /** terminal frame(`run.completed`|`run.failed`|`run.canceled`)을 받고 끝남 — 정상 종료 */ + | { reason: "terminal"; terminalFrame: TerminalFrame; lastEventId: EventId } + /** terminal 없이 끝남 — 실행 취소가 아니다. 재접속 대상이다. */ + | { + reason: "interrupted"; + /** + * - `eof`: 서버가 terminal 없이 연결을 닫음(최대 유지 시간·재시작·내부 오류) + * - `network`: 연결 실패·읽기 중 네트워크 오류 + * - `protocol`: frame이 계약 형식이 아님(JSON 오류·event/id와 data 불일치 등) — 연결을 끊었다 + */ + cause: "eof" | "network" | "protocol"; + error?: unknown; + lastEventId: EventId | null; + } + /** 호출한 쪽 `AbortSignal`로 끊음 */ + | { reason: "aborted"; lastEventId: EventId | null } + /** stream 시작 전 JSON `ApiResponse` 오류(401·409 RUN4091·429 RUN4291 등). frame은 없다. */ + | { reason: "rejected"; error: RunApiError; lastEventId: EventId | null }; + +export interface RunStreamReadOptions { + /** frame마다 순서대로 한 번 부른다. 던지면 연결을 끊고 그 오류를 그대로 던진다. */ + onFrame: (frame: BrowserFrame) => void; + signal?: AbortSignal; + /** 알면 넣는다(GET 재접속). 다른 run의 frame은 protocol 오류다. POST는 첫 frame의 runId로 고정한다. */ + runId?: Uuid; + /** 연결 시 보낸 cursor. frame 없이 끝나면 `lastEventId`로 돌려준다. */ + lastEventId?: EventId | null; +} + +/** SSE 응답 body를 끝까지(또는 terminal까지) 읽는다. 네트워크 오류를 정상 종료로 삼키지 않는다. */ +export const readRunStream = async ( + body: ReadableStream, + { onFrame, signal, runId, lastEventId = null }: RunStreamReadOptions, +): Promise => { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let streamRunId = runId; + let cursor = lastEventId; + let pendingFrames: BrowserFrame[] = []; + let protocolError: SseProtocolError | null = null; + + const parser = createSseParser((message) => { + if (protocolError) return; + try { + const frame = toBrowserFrame(message, streamRunId); + streamRunId = frame.runId; + pendingFrames.push(frame); + } catch (error) { + protocolError = error as SseProtocolError; + } + }); + + const cancelReader = () => { + reader.cancel(signal?.reason).catch(() => {}); + }; + signal?.addEventListener("abort", cancelReader); + + try { + if (signal?.aborted) return { reason: "aborted", lastEventId: cursor }; + while (true) { + let chunk: ReadableStreamReadResult; + try { + chunk = await reader.read(); + } catch (error) { + if (signal?.aborted) return { reason: "aborted", lastEventId: cursor }; + return { + reason: "interrupted", + cause: "network", + error, + lastEventId: cursor, + }; + } + + // abort 뒤에는 이미 받은 바이트도 frame으로 내지 않는다. + if (signal?.aborted) return { reason: "aborted", lastEventId: cursor }; + if (chunk.done) parser.feed(decoder.decode()); + else parser.feed(decoder.decode(chunk.value, { stream: true })); + + const frames = pendingFrames; + pendingFrames = []; + for (const frame of frames) { + if (signal?.aborted) return { reason: "aborted", lastEventId: cursor }; + onFrame(frame); + cursor = toEventId(frame.runId, frame.seq); + if (isTerminalFrame(frame)) { + // 서버는 terminal 직후 닫는다. 그 뒤 바이트는 읽지 않는다. + return { + reason: "terminal", + terminalFrame: frame, + lastEventId: cursor, + }; + } + } + + if (protocolError) + return { + reason: "interrupted", + cause: "protocol", + error: protocolError, + lastEventId: cursor, + }; + if (chunk.done) + return { reason: "interrupted", cause: "eof", lastEventId: cursor }; + } + } finally { + signal?.removeEventListener("abort", cancelReader); + cancelReader(); + reader.releaseLock(); + } +}; + +// --------------------------------------------------------------------------- +// frame 변환 +// --------------------------------------------------------------------------- + +export class SseProtocolError extends Error { + constructor(message: string) { + super(message); + this.name = "SseProtocolError"; + } +} + +const FRAME_TYPES: readonly string[] = BROWSER_FRAME_TYPES; + +/** + * SSE 메시지 하나를 durable frame으로 바꾼다. `event`=`data.type`, `id`=`{runId}:{seq}`인지만 본다. + * payload 검사·seq 순서(S1~S7)는 상태 판단 쪽 몫이다. + */ +export const toBrowserFrame = ( + message: SseMessage, + expectedRunId?: Uuid, +): BrowserFrame => { + let data: unknown; + try { + data = JSON.parse(message.data); + } catch { + throw new SseProtocolError(`data가 JSON이 아니다 (event ${message.event})`); + } + if (typeof data !== "object" || data === null || Array.isArray(data)) + throw new SseProtocolError("data가 객체가 아니다"); + const { type, runId, seq } = data as Record; + if (typeof type !== "string" || !FRAME_TYPES.includes(type)) + throw new SseProtocolError(`알 수 없는 frame type: ${String(type)}`); + if (message.event !== type) + throw new SseProtocolError( + `event(${message.event})와 type(${type})이 다르다`, + ); + if (typeof runId !== "string" || typeof seq !== "number") + throw new SseProtocolError("runId·seq가 없다"); + if (message.id !== toEventId(runId, seq)) + throw new SseProtocolError(`id(${message.id})가 ${runId}:${seq}가 아니다`); + if (expectedRunId !== undefined && runId !== expectedRunId) + throw new SseProtocolError(`다른 run의 frame: ${runId}`); + return data as BrowserFrame; +}; + +// --------------------------------------------------------------------------- +// SSE 파서 +// --------------------------------------------------------------------------- + +/** 빈 줄로 끝난, data가 있는 SSE 블록 하나 */ +export interface SseMessage { + /** `event` 필드. 없으면 `message` */ + event: string; + /** 이 블록의 `id` 필드. 없으면 null (앞 블록 id를 물려받지 않는다) */ + id: string | null; + /** data 줄을 `\n`으로 이은 값 */ + data: string; +} + +/** + * 디코드된 텍스트를 조각 단위로 받아 SSE 메시지를 낸다. 청크 경계는 어디든 될 수 있다. + * `id`는 블록 단위로만 쓴다 — data 없는 블록(heartbeat에 id가 붙은 위반 등)의 id는 cursor가 되지 않는다. + */ +export const createSseParser = (onMessage: (message: SseMessage) => void) => { + let lineBuffer = ""; + let isAfterCr = false; + let eventName = ""; + let eventId: string | null = null; + let dataLines: string[] = []; + + const dispatch = () => { + if (dataLines.length > 0) + onMessage({ + event: eventName || "message", + id: eventId, + data: dataLines.join("\n"), + }); + eventName = ""; + eventId = null; + dataLines = []; + }; + + const processLine = (line: string) => { + if (line === "") { + dispatch(); + return; + } + if (line.startsWith(":")) return; + const colon = line.indexOf(":"); + const field = colon === -1 ? line : line.slice(0, colon); + let value = colon === -1 ? "" : line.slice(colon + 1); + if (value.startsWith(" ")) value = value.slice(1); + if (field === "event") eventName = value; + else if (field === "data") dataLines.push(value); + else if (field === "id" && !value.includes("\0")) eventId = value; + // retry·알 수 없는 필드는 무시한다. + }; + + const feed = (text: string) => { + if (text === "") return; + let start = 0; + // 앞 조각이 CR로 끝났으면 이 조각 첫 LF는 같은 CRLF다. + if (isAfterCr && text.startsWith("\n")) start = 1; + isAfterCr = false; + for (let index = start; index < text.length; index++) { + const char = text[index]; + if (char !== "\r" && char !== "\n") continue; + processLine(lineBuffer + text.slice(start, index)); + lineBuffer = ""; + if (char === "\r") { + if (index + 1 === text.length) isAfterCr = true; + else if (text[index + 1] === "\n") index++; + } + start = index + 1; + } + lineBuffer += text.slice(start); + }; + + return { feed }; +}; diff --git a/src/features/run/api/test_streams.ts b/src/features/run/api/test_streams.ts new file mode 100644 index 0000000..aa54a1a --- /dev/null +++ b/src/features/run/api/test_streams.ts @@ -0,0 +1,92 @@ +/** + * run api 테스트용 ReadableStream·Response helper (테스트 전용). + */ + +const encoder = new TextEncoder(); + +export const encodeSse = (text: string) => encoder.encode(text); + +/** 바이트를 `size`씩 자른다. */ +export const splitBytes = (bytes: Uint8Array, size: number) => { + const chunks: Uint8Array[] = []; + for (let offset = 0; offset < bytes.length; offset += size) + chunks.push(bytes.slice(offset, offset + size)); + return chunks; +}; + +/** 고정 seed로 1..maxSize 바이트씩 자른다(재현 가능한 임의 분할). */ +export const splitBytesRandomly = ( + bytes: Uint8Array, + seed: number, + maxSize = 64, +) => { + let state = seed; + const nextSize = () => { + state = (state * 1103515245 + 12345) % 2147483648; + return 1 + (state % maxSize); + }; + const chunks: Uint8Array[] = []; + for (let offset = 0; offset < bytes.length; ) { + const size = nextSize(); + chunks.push(bytes.slice(offset, offset + size)); + offset += size; + } + return chunks; +}; + +export interface TestStreamOptions { + /** 모든 chunk 뒤 끝내는 방식: 정상 close(기본)·네트워크 오류·열어 둠(abort 테스트) */ + end?: "close" | "error" | "open"; +} + +export interface TestStream { + stream: ReadableStream; + /** reader가 cancel했는지 */ + isCanceled: () => boolean; +} + +export const createTestStream = ( + chunks: (Uint8Array | string)[], + { end = "close" }: TestStreamOptions = {}, +): TestStream => { + let index = 0; + let isCanceled = false; + const stream = new ReadableStream({ + pull(controller) { + if (index < chunks.length) { + const chunk = chunks[index++]; + controller.enqueue( + typeof chunk === "string" ? encodeSse(chunk) : chunk, + ); + return; + } + if (end === "close") controller.close(); + else if (end === "error") + controller.error(new TypeError("network error")); + else return new Promise(() => {}); // 다음 바이트가 오지 않는 열린 연결 + }, + cancel() { + isCanceled = true; + }, + }); + return { stream, isCanceled: () => isCanceled }; +}; + +export const sseResponse = ( + chunks: (Uint8Array | string)[], + options?: TestStreamOptions, +) => + new Response(createTestStream(chunks, options).stream, { + status: 200, + headers: { "Content-Type": "text/event-stream;charset=UTF-8" }, + }); + +export const jsonResponse = ( + status: number, + body: unknown, + headers: Record = {}, +) => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json", ...headers }, + }); diff --git a/src/shared/api/client.test.ts b/src/shared/api/client.test.ts new file mode 100644 index 0000000..66f9452 --- /dev/null +++ b/src/shared/api/client.test.ts @@ -0,0 +1,59 @@ +import axios from "axios"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { refreshAccessToken, tokenUtils } from "./client"; + +afterEach(() => { + localStorage.clear(); +}); + +describe("refreshAccessToken", () => { + it("동시에 불러도 refresh 요청은 한 번이다(refresh token 1회용)", async () => { + tokenUtils.setTokens("old-access", "old-refresh"); + let resolveRefresh!: (value: unknown) => void; + const post = vi.spyOn(axios, "post").mockReturnValue( + new Promise((resolve) => { + resolveRefresh = resolve; + }), + ); + + const first = refreshAccessToken(); + const second = refreshAccessToken(); + resolveRefresh({ + data: { + result: { accessToken: "new-access", refreshToken: "new-refresh" }, + }, + }); + + expect(await Promise.all([first, second])).toEqual([ + "new-access", + "new-access", + ]); + expect(post).toHaveBeenCalledOnce(); + expect(post.mock.calls[0][1]).toEqual({ refreshToken: "old-refresh" }); + expect(tokenUtils.getAccessToken()).toBe("new-access"); + expect(tokenUtils.getRefreshToken()).toBe("new-refresh"); + }); + + it("실패하면 기다리던 쪽도 실패하고 token을 지운다", async () => { + tokenUtils.setTokens("old-access", "old-refresh"); + const failure = new Error("refresh 거부"); + vi.spyOn(axios, "post").mockRejectedValue(failure); + + const results = await Promise.allSettled([ + refreshAccessToken(), + refreshAccessToken(), + ]); + + expect(results.map((result) => result.status)).toEqual([ + "rejected", + "rejected", + ]); + expect(tokenUtils.getAccessToken()).toBeNull(); + }); + + it("refresh token이 없으면 요청하지 않고 실패한다", async () => { + const post = vi.spyOn(axios, "post"); + await expect(refreshAccessToken()).rejects.toThrow(); + expect(post).not.toHaveBeenCalled(); + }); +}); diff --git a/src/shared/api/client.ts b/src/shared/api/client.ts index 1d09aa1..83209db 100644 --- a/src/shared/api/client.ts +++ b/src/shared/api/client.ts @@ -127,6 +127,43 @@ const processQueue = ( failedQueue = []; }; +/** + * access token을 갱신해 새 token을 돌려준다. 이미 갱신 중이면 그 결과를 기다린다. + * Server는 refresh token을 한 번 쓰면 폐기하므로, axios 밖(fetch 기반 run SSE 등)에서 401을 받았을 때도 + * interceptor와 같은 대기큐를 써서 동시 갱신을 막는다. 실패하면 토큰을 지우고 reject한다(redirect는 부른 쪽이 정한다). + */ +export const refreshAccessToken = async (): Promise => { + if (isRefreshing) { + return new Promise((resolve, reject) => { + failedQueue.push({ resolve, reject }); + }) as Promise; + } + + const refreshToken = tokenUtils.getRefreshToken(); + if (!refreshToken) { + tokenUtils.clearTokens(); + throw new Error("refresh token이 없습니다."); + } + + isRefreshing = true; + try { + const response = await axios.post>( + `${BASE_URL}/api/auth/refresh`, + { refreshToken }, + ); + const { accessToken, refreshToken: newRefreshToken } = response.data.result; + tokenUtils.setTokens(accessToken, newRefreshToken); + processQueue(null, accessToken); + return accessToken; + } catch (refreshError) { + processQueue(refreshError as AxiosError, null); + tokenUtils.clearTokens(); + throw refreshError; + } finally { + isRefreshing = false; + } +}; + apiClient.interceptors.response.use( (response) => response, async (error: AxiosError) => { From c0c788dc020ce9a7ed8678d1bc3205ac8a419633 Mon Sep 17 00:00:00 2001 From: chowon442 Date: Tue, 20 Oct 2026 05:05:20 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix:=201.89=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EA=B6=8C=EA=B3=A0=20=EB=B0=98=EC=98=81=20=E2=80=94=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0=20=EB=8C=80=EA=B8=B0=20=EC=A4=91=20abort,=20frame=20s?= =?UTF-8?q?eq=C2=B7runId=20=EA=B2=80=EC=82=AC,=20streamRunEvents=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=EA=B2=80=EC=82=AC,=20body=20=EC=9D=BD?= =?UTF-8?q?=EA=B8=B0=20=EC=8B=A4=ED=8C=A8=20=EA=B5=AC=EB=B6=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 401 갱신을 AbortSignal과 경합시켜 abort를 바로 반영하고 재요청하지 않음 - frame seq는 1..2^53-1 정수, runId는 소문자 UUID가 아니면 protocol 오류 - streamRunEvents: runId 소문자 UUID·cursor가 같은 run인지 확인, SSE 함수는 TypeError를 reject로 통일 - 오류·JSON body 읽기 중 abort·네트워크 실패를 계약 밖 오류로 삼키지 않음 - noteId·turnAttachmentIds 값이 undefined면 키가 없는 것으로 판정 Co-Authored-By: Claude Opus 5.5 --- src/features/run/api/run_api_client.test.ts | 99 ++++++++++++++++++++- src/features/run/api/run_api_client.ts | 93 +++++++++++++------ src/features/run/api/run_api_error.ts | 9 +- src/features/run/api/run_request.test.ts | 11 +++ src/features/run/api/run_request.ts | 5 +- src/features/run/api/sse_reader.test.ts | 25 ++++++ src/features/run/api/sse_reader.ts | 10 ++- 7 files changed, 215 insertions(+), 37 deletions(-) diff --git a/src/features/run/api/run_api_client.test.ts b/src/features/run/api/run_api_client.test.ts index bce0720..2f0fb44 100644 --- a/src/features/run/api/run_api_client.test.ts +++ b/src/features/run/api/run_api_client.test.ts @@ -114,14 +114,14 @@ describe("streamConversation — POST /api/conversations", () => { expect(sent[1].body).toBe(sent[0].body); }); - it("계약에 맞지 않는 요청은 보내지 않는다", () => { + it("계약에 맞지 않는 요청은 보내지 않는다", async () => { const { client, fetchMock } = setup([]); const invalid = { requestId: "7f460a7b-bf54-570e-a3af-b63a26435627", text: "a", locale: "ko", } as unknown as ConversationRunRequest; - expect(() => client.streamConversation(invalid, collect())).toThrow( + await expect(client.streamConversation(invalid, collect())).rejects.toThrow( TypeError, ); expect(fetchMock).not.toHaveBeenCalled(); @@ -359,15 +359,106 @@ describe("streamRunEvents — GET /api/ai-runs/{runId}/events", () => { } } const { client, fetchMock } = setup([]); - expect(() => + await expect( client.streamRunEvents( stream.runId, { lastEventId: `${stream.runId}:03` }, collect(), ), - ).toThrow(TypeError); + ).rejects.toThrow(TypeError); expect(fetchMock).not.toHaveBeenCalled(); }); + + it("대문자 runId·다른 run의 cursor는 보내지 않는다", async () => { + const { client, fetchMock } = setup([]); + await expect( + client.streamRunEvents(stream.runId.toUpperCase(), {}, collect()), + ).rejects.toThrow(TypeError); + const otherRunCursor = toEventId( + getStreamFixture("canceled-pending").runId, + 1, + ); + await expect( + client.streamRunEvents( + stream.runId, + { lastEventId: otherRunCursor }, + collect(), + ), + ).rejects.toThrow(TypeError); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe("body 읽기·갱신 중 abort·네트워크 실패", () => { + /** body를 읽기 시작하면 실패하는 오류 응답 */ + const brokenErrorResponse = (status: number) => + new Response( + new ReadableStream({ + pull(controller) { + controller.error(new TypeError("network error")); + }, + }), + { status, headers: { "Content-Type": "application/json" } }, + ); + + it("stream 전 오류 body를 읽다 끊기면 rejected가 아니라 interrupted(network)", async () => { + const { client } = setup([brokenErrorResponse(409)]); + expect( + await client.streamConversation( + getRequestFixture("first-no-note").request, + collect(), + ), + ).toMatchObject({ reason: "interrupted", cause: "network" }); + }); + + it("JSON 함수는 body 읽기 실패를 계약 밖 오류로 바꾸지 않고 던진다", async () => { + const ok = new Response( + new ReadableStream({ + pull(controller) { + controller.error(new TypeError("network error")); + }, + }), + { status: 200 }, + ); + const { client } = setup([ok, brokenErrorResponse(404)]); + await expect(client.getRunStatus(stream.runId)).rejects.toBeInstanceOf( + TypeError, + ); + await expect(client.cancelRun(stream.runId)).rejects.toBeInstanceOf( + TypeError, + ); + }); + + it("401 갱신을 기다리는 중 abort하면 바로 aborted, 재요청하지 않는다", async () => { + const controller = new AbortController(); + const refreshAccessToken = vi.fn(() => new Promise(() => {})); + const { client, sent } = setup( + [jsonResponse(401, getErrorFixture("AUTH4012").body)], + { getAccessToken: () => "expired", refreshAccessToken }, + ); + const endPromise = client.streamConversation( + getRequestFixture("first-no-note").request, + { ...collect(), signal: controller.signal }, + ); + await vi.waitFor(() => expect(refreshAccessToken).toHaveBeenCalled()); + controller.abort(); + expect(await endPromise).toEqual({ reason: "aborted", lastEventId: null }); + expect(sent).toHaveLength(1); + }); + + it("JSON 함수도 갱신 대기 중 abort되면 abort reason으로 reject", async () => { + const controller = new AbortController(); + const { client } = setup( + [jsonResponse(401, getErrorFixture("AUTH4012").body)], + { + getAccessToken: () => "expired", + refreshAccessToken: () => new Promise(() => {}), + }, + ); + const pending = client.getRunStatus(stream.runId, controller.signal); + controller.abort(); + await expect(pending).rejects.toMatchObject({ name: "AbortError" }); + }); }); describe("JSON API", () => { diff --git a/src/features/run/api/run_api_client.ts b/src/features/run/api/run_api_client.ts index d3fd4d2..1510b51 100644 --- a/src/features/run/api/run_api_client.ts +++ b/src/features/run/api/run_api_client.ts @@ -11,14 +11,15 @@ import type { Uuid, } from "@/shared/contracts/browser_run_v1"; import { readRunApiError, RunApiError } from "./run_api_error"; -import { validateConversationRequest } from "./run_request"; +import { isUuid, validateConversationRequest } from "./run_request"; import { readRunStream } from "./sse_reader"; import type { RunStreamEnd } from "./sse_reader"; /** * browser-run-v1 run API client (S07 Server 경로). * - * - SSE 함수(`streamConversation`·`streamRunEvents`)는 던지지 않고 `RunStreamEnd`로 한 번의 연결 결과를 돌려준다. + * - SSE 함수(`streamConversation`·`streamRunEvents`)는 한 번의 연결 결과를 `RunStreamEnd`로 돌려준다. + * reject하는 경우는 계약에 맞지 않는 입력(request·runId·cursor → `TypeError`)과 `onFrame` 예외뿐이다. * - JSON 함수는 성공 시 `result`를 돌려주고, 오류 응답은 `RunApiError`로 던진다. * fetch 자체 실패(네트워크·abort)는 fetch의 오류(`TypeError`·`AbortError`)를 그대로 던진다. * - 모든 요청은 `Authorization: Bearer`를 붙이고, stream 전 401이면 token을 한 번 갱신해 같은 요청을 다시 보낸다. @@ -68,6 +69,29 @@ export const isEventId = (value: unknown): value is EventId => EVENT_ID_PATTERN.test(value) && Number(value.slice(value.indexOf(":") + 1)) <= Number.MAX_SAFE_INTEGER; +const parseJsonOrNull = (text: string): unknown => { + try { + return JSON.parse(text); + } catch { + return null; + } +}; + +/** `promise`를 기다리되 `signal`이 abort되면 그 reason으로 바로 reject한다. */ +const raceAbort = (promise: Promise, signal?: AbortSignal | null) => { + if (!signal) return promise; + let onAbort = () => {}; + const aborted = new Promise((_, reject) => { + onAbort = () => reject(signal.reason); + if (signal.aborted) onAbort(); + else signal.addEventListener("abort", onAbort, { once: true }); + }); + promise.catch(() => {}); // abort로 진 뒤의 갱신 실패는 처리하지 않는다. + return Promise.race([promise, aborted]).finally(() => + signal.removeEventListener("abort", onAbort), + ); +}; + const SSE_ACCEPT = "text/event-stream"; const JSON_ACCEPT = "application/json"; @@ -91,8 +115,10 @@ export const createRunApiClient = ({ let token: string; try { - token = await auth.refreshAccessToken(); - } catch { + token = await raceAbort(auth.refreshAccessToken(), init.signal); + } catch (error) { + // abort는 fetch 실패처럼 던지고, 갱신 실패는 처음 401을 돌려준다. + if (init.signal?.aborted) throw error; return response; } await response.body?.cancel().catch(() => {}); @@ -106,25 +132,30 @@ export const createRunApiClient = ({ runId?: Uuid, lastEventId: EventId | null = null, ): Promise => { + const failConnection = (error: unknown): RunStreamEnd => + signal?.aborted + ? { reason: "aborted", lastEventId } + : { reason: "interrupted", cause: "network", error, lastEventId }; + let response: Response; try { response = await send(path, { ...init, signal }); } catch (error) { - if (signal?.aborted) return { reason: "aborted", lastEventId }; - return { reason: "interrupted", cause: "network", error, lastEventId }; + return failConnection(error); } - if (!response.ok) - return { - reason: "rejected", - error: await readRunApiError(response), - lastEventId, - }; const contentType = response.headers.get("Content-Type") ?? ""; - if (!contentType.startsWith(SSE_ACCEPT) || !response.body) { - // 200인데 SSE가 아니면 계약 밖 응답이다(proxy 오류 페이지 등). - const error = await readRunApiError(response); - return { reason: "rejected", error, lastEventId }; + // 200인데 SSE가 아니면 계약 밖 응답이다(proxy 오류 페이지 등). + if (!response.ok || !contentType.startsWith(SSE_ACCEPT) || !response.body) { + try { + const error = await readRunApiError(response); + // 오류 body를 다 읽은 뒤 abort됐어도 호출한 쪽에는 abort가 먼저다. + if (signal?.aborted) return { reason: "aborted", lastEventId }; + return { reason: "rejected", error, lastEventId }; + } catch (error) { + // 오류 body를 읽다가 끊긴 경우: 거부가 아니라 연결 실패다. + return failConnection(error); + } } onOpen?.(); @@ -144,9 +175,10 @@ export const createRunApiClient = ({ headers.set("Accept", JSON_ACCEPT); const response = await send(path, { ...init, headers }); if (!response.ok) throw await readRunApiError(response); - const body = (await response - .json() - .catch(() => null)) as ApiSuccessResponse | null; + // body 읽기 중 abort·네트워크 실패는 그대로 던지고, JSON이 아닌 본문만 계약 밖으로 본다. + const body = parseJsonOrNull( + await response.text(), + ) as ApiSuccessResponse | null; if (body?.isSuccess !== true) throw new RunApiError({ code: null, @@ -160,12 +192,12 @@ export const createRunApiClient = ({ /** * `POST /api/conversations` — 질문을 보내고 SSE를 seq 1(`run.created`)부터 읽는다. * 같은 전송을 다시 보낼 때는 같은 `request`(같은 requestId)를 넘긴다 — Server가 기존 run을 replay한다. - * 계약에 맞지 않는 request는 보내지 않고 `TypeError`를 던진다(`createConversationRequest`로 만든다). + * 계약에 맞지 않는 request는 보내지 않고 `TypeError`로 reject한다(`createConversationRequest`로 만든다). */ - const streamConversation = ( + const streamConversation = async ( request: ConversationRunRequest, options: RunStreamOptions, - ) => { + ): Promise => { const check = validateConversationRequest(request); if (!check.ok) throw new TypeError( @@ -186,15 +218,22 @@ export const createRunApiClient = ({ /** * `GET /api/ai-runs/{runId}/events` — cursor 다음 seq부터 replay 후 terminal이 아니면 live로 이어 받는다. - * cursor는 같은 run의 frame `id`여야 한다(아니면 `TypeError`). 형식 판정의 최종 기준은 Server(RUN4003·RUN4004)다. + * runId는 소문자 canonical UUID, cursor는 같은 run의 frame `id`여야 한다(아니면 `TypeError`로 reject). + * 형식 판정의 최종 기준은 Server(RUN4003·RUN4004)다. */ - const streamRunEvents = ( + const streamRunEvents = async ( runId: Uuid, { lastEventId = null, placement = "header" }: RunEventsCursor, options: RunStreamOptions, - ) => { - if (lastEventId != null && !isEventId(lastEventId)) - throw new TypeError(`event id 형식이 아니다: ${lastEventId}`); + ): Promise => { + // frame runId는 정확히 같은 문자열로 비교하므로 대문자 runId는 모든 frame을 protocol 오류로 만든다. + if (!isUuid(runId)) + throw new TypeError(`runId가 소문자 canonical UUID가 아니다: ${runId}`); + if ( + lastEventId != null && + (!isEventId(lastEventId) || !lastEventId.startsWith(`${runId}:`)) + ) + throw new TypeError(`이 run의 event id가 아니다: ${lastEventId}`); const headers: Record = { Accept: SSE_ACCEPT }; let query = ""; if (lastEventId != null && placement === "header") diff --git a/src/features/run/api/run_api_error.ts b/src/features/run/api/run_api_error.ts index 9846166..6ebfe0e 100644 --- a/src/features/run/api/run_api_error.ts +++ b/src/features/run/api/run_api_error.ts @@ -56,14 +56,19 @@ export class RunApiError extends Error { const isRunErrorCode = (value: unknown): value is RunErrorCode => (RUN_ERROR_CODES as readonly unknown[]).includes(value); -/** 실패 응답(`!response.ok`)의 body·header를 읽어 `RunApiError`로 만든다. body는 소비된다. */ +/** + * 실패 응답(`!response.ok`)의 body·header를 읽어 `RunApiError`로 만든다. body는 소비된다. + * body를 읽다가 실패하면(abort·네트워크) 그 오류를 던진다. + */ export const readRunApiError = async ( response: Response, now: number = Date.now(), ): Promise => { + // body 읽기 중 abort·네트워크 실패는 그대로 던진다(계약 밖 응답으로 삼키지 않는다). + const text = await response.text(); let body: unknown = null; try { - body = JSON.parse(await response.text()); + body = JSON.parse(text); } catch { // JSON이 아닌 오류 본문(proxy 502 HTML 등)은 계약 밖 응답으로 둔다. } diff --git a/src/features/run/api/run_request.test.ts b/src/features/run/api/run_request.test.ts index e0e4b9d..ee46b6b 100644 --- a/src/features/run/api/run_request.test.ts +++ b/src/features/run/api/run_request.test.ts @@ -44,6 +44,17 @@ describe("validateConversationRequest — fixture requests", () => { expect(listInvalidRequestFixtures()).toHaveLength(43); }); + it("값이 undefined인 선택 키는 JSON에 실리지 않으므로 없는 것과 같다", () => { + expect( + validateConversationRequest({ + requestId: "7f460a7b-bf54-570e-a3af-b63a26435627", + text: "a", + noteId: undefined, + turnAttachmentIds: undefined, + }).ok, + ).toBe(true); + }); + it("구조 위반과 video가 함께 있으면 RUN4001이 먼저다", () => { const result = validateConversationRequest({ requestId: "7f460a7b-bf54-570e-a3af-b63a26435627", diff --git a/src/features/run/api/run_request.ts b/src/features/run/api/run_request.ts index e1bdb8d..8daecfc 100644 --- a/src/features/run/api/run_request.ts +++ b/src/features/run/api/run_request.ts @@ -198,7 +198,8 @@ export const validateConversationRequest = ( if (!isUuid(value.requestId)) reject("requestId", "소문자 canonical UUID가 아니다"); - if ("noteId" in value && !isNoteId(value.noteId)) + // undefined 값은 JSON에 실리지 않으므로 키가 없는 것과 같다. + if (value.noteId !== undefined && !isNoteId(value.noteId)) reject("noteId", "선행 0 없는 10진 문자열 1..2^63-1이 아니다"); checkBoundedText(value.text, "text", reject); @@ -222,7 +223,7 @@ export const validateConversationRequest = ( ) reject("requestedOutputs", "video는 Phase 1 미지원", "RUN4002"); - if ("turnAttachmentIds" in value) { + if (value.turnAttachmentIds !== undefined) { const ids = value.turnAttachmentIds; if (!Array.isArray(ids)) reject("turnAttachmentIds", "배열이 아니다"); else { diff --git a/src/features/run/api/sse_reader.test.ts b/src/features/run/api/sse_reader.test.ts index f4bedce..72474d5 100644 --- a/src/features/run/api/sse_reader.test.ts +++ b/src/features/run/api/sse_reader.test.ts @@ -343,6 +343,31 @@ describe("readRunStream — protocol 오류", () => { }); }); + it.each([ + ["seq 0", 0], + ["seq 음수", -1], + ["seq 소수", 1.5], + ["seq 2^53", 2 ** 53], + ])("%s → cursor로 쓰지 않고 protocol 오류", async (_name, seq) => { + const data = { ...fixture.frames[0], seq }; + const bad = `event: run.created\nid: ${fixture.runId}:${seq}\ndata: ${JSON.stringify(data)}\n\n`; + const { frames, end } = await readAll([bad]); + expect(frames).toEqual([]); + expect(end).toMatchObject({ + reason: "interrupted", + cause: "protocol", + lastEventId: null, + }); + }); + + it("runId가 소문자 UUID가 아니면 protocol 오류", async () => { + const runId = fixture.runId.toUpperCase(); + const data = { ...fixture.frames[0], runId }; + const bad = `event: run.created\nid: ${runId}:1\ndata: ${JSON.stringify(data)}\n\n`; + const { end } = await readAll([bad]); + expect(end).toMatchObject({ reason: "interrupted", cause: "protocol" }); + }); + it("GET 재접속에서 다른 run의 frame은 protocol 오류", async () => { const other = getStreamFixture("substantive-completed"); const { frames, end } = await readAll( diff --git a/src/features/run/api/sse_reader.ts b/src/features/run/api/sse_reader.ts index d9b8bf3..1e7deb7 100644 --- a/src/features/run/api/sse_reader.ts +++ b/src/features/run/api/sse_reader.ts @@ -10,6 +10,7 @@ import type { Uuid, } from "@/shared/contracts/browser_run_v1"; import type { RunApiError } from "./run_api_error"; +import { isUuid } from "./run_request"; /** * fetch 기반 browser-run SSE reader. @@ -178,8 +179,13 @@ export const toBrowserFrame = ( throw new SseProtocolError( `event(${message.event})와 type(${type})이 다르다`, ); - if (typeof runId !== "string" || typeof seq !== "number") - throw new SseProtocolError("runId·seq가 없다"); + // cursor를 만드는 필드는 reader가 보장한다(다음 재접속 cursor가 계약 형식이어야 한다). + if (!isUuid(runId)) + throw new SseProtocolError( + `runId가 소문자 UUID가 아니다: ${String(runId)}`, + ); + if (typeof seq !== "number" || !Number.isSafeInteger(seq) || seq < 1) + throw new SseProtocolError(`seq가 1..2^53-1 정수가 아니다: ${String(seq)}`); if (message.id !== toEventId(runId, seq)) throw new SseProtocolError(`id(${message.id})가 ${runId}:${seq}가 아니다`); if (expectedRunId !== undefined && runId !== expectedRunId) From 8605de48b70b7f75598e275e32265412974211de Mon Sep 17 00:00:00 2001 From: chowon442 Date: Tue, 20 Oct 2026 10:26:21 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20axios=20interceptor=EA=B0=80=20refre?= =?UTF-8?q?shAccessToken()=EC=9C=BC=EB=A1=9C=20=EA=B0=B1=EC=8B=A0=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=ED=86=B5=ED=95=A9=20(1.89=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20#1=C2=B7#7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - interceptor 401 분기의 갱신 본문 복제를 없애고 refreshAccessToken()을 기다린 뒤 재요청한다. - refresh token이 없을 때 isRefreshing이 true로 남아 이후 갱신이 끝나지 않던 기존 결함을 고친다. - 갱신 실패 시 /error/401 redirect, refresh token 없음 시 원래 401 reject 등 기존 동작을 유지하고 테스트로 확인한다. - 다른 경로(fetch)가 시작한 갱신이 실패해도 기다리던 axios 요청이 redirect한다(#6 간접 차이 해소). Co-Authored-By: Claude Opus 5.5 --- src/features/run/api/run_api_client.ts | 2 +- src/shared/api/client.test.ts | 179 ++++++++++++++++++++++++- src/shared/api/client.ts | 61 +++------ 3 files changed, 194 insertions(+), 48 deletions(-) diff --git a/src/features/run/api/run_api_client.ts b/src/features/run/api/run_api_client.ts index 1510b51..d388e54 100644 --- a/src/features/run/api/run_api_client.ts +++ b/src/features/run/api/run_api_client.ts @@ -117,7 +117,7 @@ export const createRunApiClient = ({ try { token = await raceAbort(auth.refreshAccessToken(), init.signal); } catch (error) { - // abort는 fetch 실패처럼 던지고, 갱신 실패는 처음 401을 돌려준다. + // abort는 fetch 실패처럼 던지고, 갱신 실패는 처음 401을 돌려준다(redirect는 화면이 정한다). if (init.signal?.aborted) throw error; return response; } diff --git a/src/shared/api/client.test.ts b/src/shared/api/client.test.ts index 66f9452..a0397db 100644 --- a/src/shared/api/client.test.ts +++ b/src/shared/api/client.test.ts @@ -1,6 +1,13 @@ -import axios from "axios"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { refreshAccessToken, tokenUtils } from "./client"; +import axios, { AxiosError } from "axios"; +import type { AxiosAdapter, InternalAxiosRequestConfig } from "axios"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// redirect 여부는 client 모듈을 읽을 때 정해지므로 import보다 먼저 켠다. +vi.hoisted(() => { + vi.stubEnv("VITE_ENABLE_ERROR_REDIRECT", "true"); +}); + +import { apiClient, refreshAccessToken, tokenUtils } from "./client"; afterEach(() => { localStorage.clear(); @@ -57,3 +64,169 @@ describe("refreshAccessToken", () => { expect(post).not.toHaveBeenCalled(); }); }); + +describe("apiClient 401 처리", () => { + const navigations: string[] = []; + const recordNavigation = (event: Event) => { + navigations.push((event as CustomEvent<{ route: string }>).detail.route); + }; + + beforeEach(() => { + navigations.length = 0; + window.addEventListener("proovy:navigate", recordNavigation); + return () => + window.removeEventListener("proovy:navigate", recordNavigation); + }); + + /** `valid-access`만 통과시키고 나머지는 401로 돌려주는 adapter. 받은 Authorization을 기록한다. */ + const createAdapter = () => { + const authorizations: unknown[] = []; + const adapter: AxiosAdapter = async (config) => { + authorizations.push(config.headers.Authorization); + if (config.headers.Authorization === "Bearer valid-access") + return { + data: "ok", + status: 200, + statusText: "OK", + headers: {}, + config, + }; + const response = { + data: { isSuccess: false, code: "TOKEN401" }, + status: 401, + statusText: "Unauthorized", + headers: {}, + config, + }; + throw new AxiosError( + "401", + AxiosError.ERR_BAD_REQUEST, + config as InternalAxiosRequestConfig, + null, + response, + ); + }; + return { adapter, authorizations }; + }; + + const refreshSucceeds = () => + vi.spyOn(axios, "post").mockResolvedValue({ + data: { + result: { accessToken: "valid-access", refreshToken: "new-refresh" }, + }, + }); + + it("401이면 갱신하고 새 token으로 재요청한다", async () => { + tokenUtils.setTokens("old-access", "old-refresh"); + const post = refreshSucceeds(); + const { adapter, authorizations } = createAdapter(); + + const response = await apiClient.get("/api/x", { adapter }); + + expect(response.data).toBe("ok"); + expect(post).toHaveBeenCalledOnce(); + expect(authorizations).toEqual([ + "Bearer old-access", + "Bearer valid-access", + ]); + expect(tokenUtils.getRefreshToken()).toBe("new-refresh"); + expect(navigations).toEqual([]); + }); + + it("동시에 받은 401은 갱신 한 번을 기다려 모두 재요청한다", async () => { + tokenUtils.setTokens("old-access", "old-refresh"); + const post = refreshSucceeds(); + const { adapter } = createAdapter(); + + const responses = await Promise.all([ + apiClient.get("/api/a", { adapter }), + apiClient.get("/api/b", { adapter }), + ]); + + expect(responses.map((response) => response.data)).toEqual(["ok", "ok"]); + expect(post).toHaveBeenCalledOnce(); + }); + + it("갱신이 실패하면 token을 지우고 /error/401로 보내며 갱신 오류로 reject한다", async () => { + tokenUtils.setTokens("old-access", "old-refresh"); + const failure = new Error("refresh 거부"); + vi.spyOn(axios, "post").mockRejectedValue(failure); + const { adapter } = createAdapter(); + + await expect(apiClient.get("/api/x", { adapter })).rejects.toBe(failure); + expect(tokenUtils.getAccessToken()).toBeNull(); + expect(navigations).toEqual(["/error/401"]); + }); + + it("refresh token이 없으면 원래 401로 reject하고, 갱신 상태가 고착되지 않는다", async () => { + localStorage.setItem("accessToken", "old-access"); + const post = refreshSucceeds(); + const { adapter } = createAdapter(); + + const rejection = await apiClient + .get("/api/x", { adapter }) + .catch((error: unknown) => error); + expect(rejection).toBeInstanceOf(AxiosError); + expect((rejection as AxiosError).response?.status).toBe(401); + expect(navigations).toEqual(["/error/401"]); + expect(post).not.toHaveBeenCalled(); + + // 예전에는 isRefreshing이 true로 남아 이후 갱신이 대기큐에서 끝나지 않았다. + tokenUtils.setTokens("old-access", "old-refresh"); + await expect(refreshAccessToken()).resolves.toBe("valid-access"); + }); + + it("skipErrorRedirect면 갱신 실패에도 redirect하지 않는다", async () => { + tokenUtils.setTokens("old-access", "old-refresh"); + vi.spyOn(axios, "post").mockRejectedValue(new Error("refresh 거부")); + const { adapter } = createAdapter(); + + await expect( + apiClient.get("/api/x", { adapter, skipErrorRedirect: true } as never), + ).rejects.toThrow("refresh 거부"); + expect(navigations).toEqual([]); + }); + + it("axios 밖에서 시작한 갱신이 실패해도 기다리던 axios 요청은 /error/401로 보낸다", async () => { + tokenUtils.setTokens("old-access", "old-refresh"); + let rejectRefresh!: (reason: unknown) => void; + vi.spyOn(axios, "post").mockReturnValue( + new Promise((_, reject) => { + rejectRefresh = reject; + }), + ); + const { adapter, authorizations } = createAdapter(); + + const external = refreshAccessToken(); + const request = apiClient.get("/api/x", { adapter }); + // axios 요청이 401을 받고 갱신 대기큐에 들어갈 때까지 기다린다. + await vi.waitFor(() => expect(authorizations).toHaveLength(1)); + await new Promise((resolve) => setTimeout(resolve, 0)); + rejectRefresh(new Error("refresh 거부")); + + await expect(external).rejects.toThrow("refresh 거부"); + await expect(request).rejects.toThrow("refresh 거부"); + expect(axios.post).toHaveBeenCalledOnce(); + expect(navigations).toEqual(["/error/401"]); + }); + + it("재요청이 다시 401이면 또 갱신하지 않는다", async () => { + tokenUtils.setTokens("old-access", "old-refresh"); + const post = vi.spyOn(axios, "post").mockResolvedValue({ + data: { + result: { accessToken: "still-invalid", refreshToken: "new-refresh" }, + }, + }); + const { adapter, authorizations } = createAdapter(); + + await expect(apiClient.get("/api/x", { adapter })).rejects.toBeInstanceOf( + AxiosError, + ); + expect(post).toHaveBeenCalledOnce(); + expect(authorizations).toEqual([ + "Bearer old-access", + "Bearer still-invalid", + ]); + expect(navigations).toEqual(["/error/401"]); + }); +}); diff --git a/src/shared/api/client.ts b/src/shared/api/client.ts index 83209db..ffd7164 100644 --- a/src/shared/api/client.ts +++ b/src/shared/api/client.ts @@ -127,10 +127,17 @@ const processQueue = ( failedQueue = []; }; +class MissingRefreshTokenError extends Error { + constructor() { + super("refresh token이 없습니다."); + this.name = "MissingRefreshTokenError"; + } +} + /** * access token을 갱신해 새 token을 돌려준다. 이미 갱신 중이면 그 결과를 기다린다. - * Server는 refresh token을 한 번 쓰면 폐기하므로, axios 밖(fetch 기반 run SSE 등)에서 401을 받았을 때도 - * interceptor와 같은 대기큐를 써서 동시 갱신을 막는다. 실패하면 토큰을 지우고 reject한다(redirect는 부른 쪽이 정한다). + * Server는 refresh token을 한 번 쓰면 폐기하므로, axios interceptor와 axios 밖(fetch 기반 run SSE 등)이 + * 모두 이 함수로 갱신해 동시 갱신을 막는다. 실패하면 토큰을 지우고 reject한다(redirect는 부른 쪽이 정한다). */ export const refreshAccessToken = async (): Promise => { if (isRefreshing) { @@ -142,7 +149,7 @@ export const refreshAccessToken = async (): Promise => { const refreshToken = tokenUtils.getRefreshToken(); if (!refreshToken) { tokenUtils.clearTokens(); - throw new Error("refresh token이 없습니다."); + throw new MissingRefreshTokenError(); } isRefreshing = true; @@ -179,56 +186,22 @@ apiClient.interceptors.response.use( originalRequest?.meta?.suppressRedirect, ); - // 401 에러이고 재시도하지 않은 요청인 경우 + // 401 에러이고 재시도하지 않은 요청인 경우: 갱신(이미 갱신 중이면 그 결과를 기다림) 후 재요청 if (error.response?.status === 401 && !originalRequest._retry) { - if (isRefreshing) { - // 이미 토큰 갱신 중이면 큐에 추가 - return new Promise((resolve, reject) => { - failedQueue.push({ resolve, reject }); - }) - .then((token) => { - originalRequest.headers.Authorization = `Bearer ${token}`; - return apiClient(originalRequest); - }) - .catch((err) => Promise.reject(err)); - } - originalRequest._retry = true; - isRefreshing = true; - - const refreshToken = tokenUtils.getRefreshToken(); - - if (!refreshToken) { - tokenUtils.clearTokens(); - if (!shouldSuppressRedirect) { - redirectToErrorRoute("/error/401"); - } - return Promise.reject(error); - } - try { - const response = await axios.post>( - `${BASE_URL}/api/auth/refresh`, - { refreshToken }, - ); - - const { accessToken, refreshToken: newRefreshToken } = - response.data.result; - tokenUtils.setTokens(accessToken, newRefreshToken); - - processQueue(null, accessToken); - + const accessToken = await refreshAccessToken(); originalRequest.headers.Authorization = `Bearer ${accessToken}`; return apiClient(originalRequest); } catch (refreshError) { - processQueue(refreshError as AxiosError, null); - tokenUtils.clearTokens(); if (!shouldSuppressRedirect) { redirectToErrorRoute("/error/401"); } - return Promise.reject(refreshError); - } finally { - isRefreshing = false; + return Promise.reject( + refreshError instanceof MissingRefreshTokenError + ? error + : refreshError, + ); } }