Initial Commit
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest"
|
||||
import axios from "axios"
|
||||
import MockAdapter from "axios-mock-adapter"
|
||||
import { apiClient, apiGet, apiPost, apiList } from "./client"
|
||||
import { ApiError } from "./errors"
|
||||
import { useSessionExpiryStore } from "@/features/auth/store/sessionExpiryStore"
|
||||
import { setAccessToken } from "@/lib/auth/tokenProvider"
|
||||
|
||||
const BASE = "http://localhost:8001/api/v1"
|
||||
|
||||
function envelope<T>(data: T, meta: unknown = null, counts: number | null = null) {
|
||||
return {
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
code: null,
|
||||
message: null,
|
||||
data,
|
||||
counts,
|
||||
errors: [] as string[],
|
||||
timestamp: "2026-01-01T00:00:00Z",
|
||||
meta,
|
||||
}
|
||||
}
|
||||
|
||||
function errorEnvelope(opts: {
|
||||
statusCode: number
|
||||
message: string
|
||||
code?: string
|
||||
errors?: string[]
|
||||
}) {
|
||||
return {
|
||||
success: false,
|
||||
statusCode: opts.statusCode,
|
||||
code: opts.code ?? null,
|
||||
message: opts.message,
|
||||
data: null,
|
||||
counts: null,
|
||||
errors: opts.errors ?? [opts.message],
|
||||
timestamp: "2026-01-01T00:00:00Z",
|
||||
meta: null,
|
||||
}
|
||||
}
|
||||
|
||||
let clientMock: MockAdapter
|
||||
let globalMock: MockAdapter
|
||||
|
||||
beforeEach(() => {
|
||||
clientMock = new MockAdapter(apiClient)
|
||||
// refresh는 글로벌 axios.post로 호출 (인터셉터 우회 위해)
|
||||
globalMock = new MockAdapter(axios)
|
||||
useSessionExpiryStore.setState({ open: false, queue: [] })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clientMock.restore()
|
||||
globalMock.restore()
|
||||
})
|
||||
|
||||
describe("apiGet / apiPost / unwrap", () => {
|
||||
it("성공 envelope이면 data만 반환", async () => {
|
||||
clientMock.onGet("/users/me").reply(200, envelope({ id: "u1", email: "x@x.com" }))
|
||||
const result = await apiGet<{ id: string; email: string }>("/users/me")
|
||||
expect(result).toEqual({ id: "u1", email: "x@x.com" })
|
||||
})
|
||||
|
||||
it("withCredentials=true 가 인스턴스 default로 박힘", async () => {
|
||||
expect(apiClient.defaults.withCredentials).toBe(true)
|
||||
})
|
||||
|
||||
it("baseURL 이 env 값으로 설정됨", () => {
|
||||
expect(apiClient.defaults.baseURL).toBe(BASE)
|
||||
})
|
||||
|
||||
it("apiPost: body 직렬화 + 응답 unwrap", async () => {
|
||||
clientMock.onPost("/users", { email: "x@x.com" }).reply(200, envelope({ id: 1 }))
|
||||
const result = await apiPost<{ id: number }>("/users", { email: "x@x.com" })
|
||||
expect(result).toEqual({ id: 1 })
|
||||
})
|
||||
|
||||
it("응답 envelope success=false 면 ApiError reject (메시지·code·errors 추출)", async () => {
|
||||
clientMock.onGet("/missing").reply(
|
||||
404,
|
||||
errorEnvelope({
|
||||
statusCode: 404,
|
||||
message: "메모를 찾을 수 없음",
|
||||
code: "MEMO_NOT_FOUND",
|
||||
})
|
||||
)
|
||||
await expect(apiGet("/missing")).rejects.toMatchObject({
|
||||
name: "ApiError",
|
||||
status: 404,
|
||||
message: "메모를 찾을 수 없음",
|
||||
code: "MEMO_NOT_FOUND",
|
||||
})
|
||||
})
|
||||
|
||||
it("200 응답에 envelope.success=false 박혀와도 ApiError reject", async () => {
|
||||
clientMock
|
||||
.onGet("/weird")
|
||||
.reply(200, errorEnvelope({ statusCode: 400, message: "validation 실패" }))
|
||||
await expect(apiGet("/weird")).rejects.toBeInstanceOf(ApiError)
|
||||
})
|
||||
})
|
||||
|
||||
describe("apiList", () => {
|
||||
it("envelope에서 items / meta / counts 추출", async () => {
|
||||
const meta = {
|
||||
currentPage: 1,
|
||||
pageSize: 10,
|
||||
totalItems: 25,
|
||||
totalPages: 3,
|
||||
hasNextPage: true,
|
||||
hasPreviousPage: false,
|
||||
}
|
||||
clientMock.onGet("/users").reply(200, envelope([{ id: "a" }, { id: "b" }], meta, 2))
|
||||
|
||||
const result = await apiList<{ id: string }>("/users")
|
||||
expect(result.items).toEqual([{ id: "a" }, { id: "b" }])
|
||||
expect(result.meta).toEqual(meta)
|
||||
expect(result.counts).toBe(2)
|
||||
})
|
||||
|
||||
it("data가 null이면 items 빈 배열로 fallback", async () => {
|
||||
clientMock.onGet("/users").reply(200, envelope(null))
|
||||
const result = await apiList<{ id: string }>("/users")
|
||||
expect(result.items).toEqual([])
|
||||
expect(result.counts).toBe(0)
|
||||
})
|
||||
|
||||
it("query params 전달", async () => {
|
||||
clientMock.onGet("/users").reply((config) => {
|
||||
expect(config.params).toEqual({ page: 2, search: "abc" })
|
||||
return [200, envelope([], null, 0)]
|
||||
})
|
||||
await apiList<{ id: string }>("/users", { params: { page: 2, search: "abc" } })
|
||||
})
|
||||
})
|
||||
|
||||
describe("401 인터셉터 — refresh 성공", () => {
|
||||
it("401 → refresh 호출 → 원 요청 재시도", async () => {
|
||||
clientMock
|
||||
.onGet("/users/me")
|
||||
.replyOnce(401, errorEnvelope({ statusCode: 401, message: "expired" }))
|
||||
.onGet("/users/me")
|
||||
.replyOnce(200, envelope({ id: "u1" }))
|
||||
|
||||
globalMock.onPost(`${BASE}/auth/refresh`).reply(200, envelope({ token: "new" }))
|
||||
|
||||
const result = await apiGet<{ id: string }>("/users/me")
|
||||
expect(result).toEqual({ id: "u1" })
|
||||
expect(globalMock.history.post.some((r) => r.url?.endsWith("/auth/refresh"))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("401 인터셉터 — refresh 실패", () => {
|
||||
it("refresh가 401이면 sessionExpiryStore에 큐 push + open=true", async () => {
|
||||
clientMock.onGet("/users/me").reply(401, errorEnvelope({ statusCode: 401, message: "expired" }))
|
||||
globalMock
|
||||
.onPost(`${BASE}/auth/refresh`)
|
||||
.reply(401, errorEnvelope({ statusCode: 401, message: "bad refresh" }))
|
||||
|
||||
// pushFailure는 retry 함수를 큐에 보관 → resolve/reject는 closeAndFlush 시점.
|
||||
// 여기선 promise를 띄워두기만.
|
||||
const promise = apiGet("/users/me")
|
||||
// 인터셉터 처리 시간 확보
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
const state = useSessionExpiryStore.getState()
|
||||
expect(state.open).toBe(true)
|
||||
expect(state.queue.length).toBe(1)
|
||||
|
||||
// dangling promise 정리 — cancel하면 큐가 폐기됨. 결과는 cancel로 reject되지 않으니 catch만.
|
||||
state.cancel()
|
||||
promise.catch(() => {})
|
||||
})
|
||||
})
|
||||
|
||||
describe("401 인터셉터 — 인증 초기화", () => {
|
||||
it("refresh 실패를 세션 만료 큐에 넣지 않고 호출자에게 반환", async () => {
|
||||
clientMock.onGet("/users/me").reply(401, errorEnvelope({ statusCode: 401, message: "no auth" }))
|
||||
globalMock
|
||||
.onPost(`${BASE}/auth/refresh`)
|
||||
.reply(401, errorEnvelope({ statusCode: 401, message: "no refresh" }))
|
||||
|
||||
await expect(apiGet("/users/me", { __skipSessionExpiry: true })).rejects.toBeInstanceOf(
|
||||
ApiError
|
||||
)
|
||||
expect(useSessionExpiryStore.getState().open).toBe(false)
|
||||
expect(useSessionExpiryStore.getState().queue).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("__skipAuth — public 엔드포인트 401 시 모달 안 띄움", () => {
|
||||
it("logout 같은 skipAuth 요청은 401에서도 sessionExpiryStore 안 건드림", async () => {
|
||||
clientMock
|
||||
.onPost("/auth/logout")
|
||||
.reply(401, errorEnvelope({ statusCode: 401, message: "no session" }))
|
||||
|
||||
await expect(apiPost("/auth/logout", undefined, { __skipAuth: true })).rejects.toBeInstanceOf(
|
||||
ApiError
|
||||
)
|
||||
|
||||
expect(useSessionExpiryStore.getState().open).toBe(false)
|
||||
expect(useSessionExpiryStore.getState().queue.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("apiClient Bearer 주입", () => {
|
||||
afterEach(() => {
|
||||
setAccessToken(null)
|
||||
delete apiClient.defaults.adapter
|
||||
})
|
||||
|
||||
it("토큰 있으면 Authorization: Bearer 헤더를 붙인다", async () => {
|
||||
setAccessToken("tok123")
|
||||
let seen: unknown
|
||||
apiClient.defaults.adapter = async (config) => {
|
||||
seen = config.headers.Authorization
|
||||
return {
|
||||
data: { success: true, data: null },
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {},
|
||||
config,
|
||||
} as never
|
||||
}
|
||||
await apiClient.get("/ping")
|
||||
expect(seen).toBe("Bearer tok123")
|
||||
})
|
||||
|
||||
it("토큰 없으면 Authorization 를 안 붙인다(쿠키 모드)", async () => {
|
||||
setAccessToken(null)
|
||||
let seen: unknown = "sentinel"
|
||||
apiClient.defaults.adapter = async (config) => {
|
||||
seen = config.headers.Authorization
|
||||
return {
|
||||
data: { success: true, data: null },
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {},
|
||||
config,
|
||||
} as never
|
||||
}
|
||||
await apiClient.get("/ping")
|
||||
expect(seen).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,149 @@
|
||||
import axios, { AxiosError, type AxiosRequestConfig, type InternalAxiosRequestConfig } from "axios"
|
||||
import { env } from "@/config/env"
|
||||
import { useSessionExpiryStore } from "@/features/auth/store/sessionExpiryStore"
|
||||
import { getAccessToken } from "@/lib/auth/tokenProvider"
|
||||
import { ApiError } from "./errors"
|
||||
import type { CommonResponse, Meta } from "@/types/api"
|
||||
|
||||
/**
|
||||
* 단일 axios 인스턴스. 모든 인증 보호 API는 이걸 통해 호출.
|
||||
*
|
||||
* - `withCredentials: true` 로 httpOnly 쿠키(`accessToken`/`refreshToken`/`accessTokenExp`) 자동 전송
|
||||
* - 응답 인터셉터에서 envelope 검사 (`success === false` → ApiError reject)
|
||||
* - 401 인터셉터: refresh → 재시도 → 실패 시 sessionExpiryStore 큐에 push (모달 노출)
|
||||
* - public 엔드포인트(`__skipAuth`)는 401 흐름 우회
|
||||
*
|
||||
* 호출자는 보통 helper(`apiGet`/`apiPost`/`apiPatch`/`apiDelete`/`apiList`) 사용.
|
||||
* envelope 직접 다뤄야 하면 `apiClient.get<CommonResponse<T>>(...)` 처럼 raw 호출.
|
||||
*/
|
||||
export const apiClient = axios.create({
|
||||
baseURL: env.apiBaseUrl,
|
||||
withCredentials: true,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
|
||||
interface AuthMeta {
|
||||
__isRetry?: boolean
|
||||
__skipAuth?: boolean
|
||||
__skipSessionExpiry?: boolean
|
||||
}
|
||||
|
||||
type RequestConfig = InternalAxiosRequestConfig & AuthMeta
|
||||
export type CallerConfig = AxiosRequestConfig & AuthMeta
|
||||
|
||||
// .NET 웹뷰 호스트가 토큰을 주입하면 Bearer 로, 아니면(=오늘) 쿠키로.
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
const token = getAccessToken()
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
return config
|
||||
})
|
||||
|
||||
let inflightRefresh: Promise<boolean> | null = null
|
||||
|
||||
async function performRefresh(): Promise<boolean> {
|
||||
if (inflightRefresh) return inflightRefresh
|
||||
inflightRefresh = (async () => {
|
||||
try {
|
||||
// baseURL 그대로 사용. validateStatus로 인터셉터 reject 우회 (무한루프 방지)
|
||||
const res = await axios.post(`${env.apiBaseUrl}/auth/refresh`, undefined, {
|
||||
withCredentials: true,
|
||||
validateStatus: () => true,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
const body = res.data as CommonResponse<unknown> | undefined
|
||||
return res.status >= 200 && res.status < 300 && body?.success === true
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
inflightRefresh = null
|
||||
}
|
||||
})()
|
||||
return inflightRefresh
|
||||
}
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => {
|
||||
const env = response.data as CommonResponse<unknown> | undefined
|
||||
if (env && typeof env === "object" && "success" in env && env.success === false) {
|
||||
throw new ApiError(
|
||||
env.statusCode ?? response.status,
|
||||
env.message ?? "API error",
|
||||
env.errors ?? [],
|
||||
env.code ?? null
|
||||
)
|
||||
}
|
||||
return response
|
||||
},
|
||||
async (error: AxiosError) => {
|
||||
const status = error.response?.status
|
||||
const config = error.config as RequestConfig | undefined
|
||||
|
||||
if (status === 401 && config && !config.__isRetry && !config.__skipAuth) {
|
||||
const ok = await performRefresh()
|
||||
if (ok) {
|
||||
const retryConfig: RequestConfig = { ...config, __isRetry: true }
|
||||
return apiClient.request(retryConfig)
|
||||
}
|
||||
// 앱 시작 때 저장된 user를 검증하는 요청은 모달에 가두지 않고 호출자에게 실패를 돌려준다.
|
||||
if (!config.__skipSessionExpiry) {
|
||||
return useSessionExpiryStore.getState().pushFailure(() => {
|
||||
const retryConfig: RequestConfig = { ...config, __isRetry: true }
|
||||
return apiClient.request(retryConfig)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const env = error.response?.data as CommonResponse<unknown> | undefined
|
||||
if (env && typeof env === "object" && "success" in env && env.success === false) {
|
||||
throw new ApiError(
|
||||
env.statusCode ?? status ?? 0,
|
||||
env.message ?? error.message,
|
||||
env.errors ?? [],
|
||||
env.code ?? null
|
||||
)
|
||||
}
|
||||
throw new ApiError(status ?? 0, error.message)
|
||||
}
|
||||
)
|
||||
|
||||
// ---- envelope 헬퍼 (caller가 unwrapped 값만 받도록) ----
|
||||
|
||||
export async function apiGet<T>(path: string, config?: CallerConfig): Promise<T> {
|
||||
const res = await apiClient.get<CommonResponse<T>>(path, config)
|
||||
return res.data.data as T
|
||||
}
|
||||
|
||||
export async function apiPost<T>(path: string, body?: unknown, config?: CallerConfig): Promise<T> {
|
||||
const res = await apiClient.post<CommonResponse<T>>(path, body, config)
|
||||
return res.data.data as T
|
||||
}
|
||||
|
||||
export async function apiPatch<T>(path: string, body?: unknown, config?: CallerConfig): Promise<T> {
|
||||
const res = await apiClient.patch<CommonResponse<T>>(path, body, config)
|
||||
return res.data.data as T
|
||||
}
|
||||
|
||||
export async function apiPut<T>(path: string, body?: unknown, config?: CallerConfig): Promise<T> {
|
||||
const res = await apiClient.put<CommonResponse<T>>(path, body, config)
|
||||
return res.data.data as T
|
||||
}
|
||||
|
||||
export async function apiDelete<T = void>(path: string, config?: CallerConfig): Promise<T> {
|
||||
const res = await apiClient.delete<CommonResponse<T>>(path, config)
|
||||
return res.data.data as T
|
||||
}
|
||||
|
||||
export interface ListResult<T> {
|
||||
items: T[]
|
||||
meta: Meta | null
|
||||
counts: number
|
||||
}
|
||||
|
||||
export async function apiList<T>(path: string, config?: CallerConfig): Promise<ListResult<T>> {
|
||||
const res = await apiClient.get<CommonResponse<T[]>>(path, config)
|
||||
return {
|
||||
items: res.data.data ?? [],
|
||||
meta: res.data.meta,
|
||||
counts: res.data.counts ?? 0,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { ApiError } from "./errors"
|
||||
|
||||
describe("ApiError", () => {
|
||||
it("status / message / errors / code 보유", () => {
|
||||
const err = new ApiError(401, "invalid token", ["invalid token"], "INVALID_CREDENTIALS")
|
||||
expect(err.status).toBe(401)
|
||||
expect(err.message).toBe("invalid token")
|
||||
expect(err.errors).toEqual(["invalid token"])
|
||||
expect(err.code).toBe("INVALID_CREDENTIALS")
|
||||
})
|
||||
|
||||
it("errors 기본값 빈 배열, code 기본값 null", () => {
|
||||
const err = new ApiError(500, "server error")
|
||||
expect(err.errors).toEqual([])
|
||||
expect(err.code).toBeNull()
|
||||
})
|
||||
|
||||
it("ApiError instance 가드", () => {
|
||||
const err = new ApiError(400, "bad")
|
||||
expect(err).toBeInstanceOf(ApiError)
|
||||
expect(err).toBeInstanceOf(Error)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* 백엔드 envelope `{ success: false, statusCode, message, errors[], code }` 에 1:1 대응.
|
||||
* axios 응답 인터셉터에서 만들어지고 caller가 instanceof로 분기.
|
||||
*/
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
errors: string[]
|
||||
code: string | null
|
||||
|
||||
constructor(status: number, message: string, errors: string[] = [], code: string | null = null) {
|
||||
super(message)
|
||||
this.name = "ApiError"
|
||||
this.status = status
|
||||
this.errors = errors
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest"
|
||||
|
||||
const mockLoginPopup = vi.fn()
|
||||
const mockAcquireTokenSilent = vi.fn()
|
||||
const mockInitialize = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
vi.mock("@azure/msal-browser", () => ({
|
||||
PublicClientApplication: vi.fn().mockImplementation(() => ({
|
||||
initialize: mockInitialize,
|
||||
loginPopup: mockLoginPopup,
|
||||
acquireTokenSilent: mockAcquireTokenSilent,
|
||||
getAllAccounts: vi.fn().mockReturnValue([]),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock("@/features/auth/api/auth.api", () => ({
|
||||
authApi: {
|
||||
getEntraConfig: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("getEntraConfig", () => {
|
||||
it("authApi.getEntraConfig 200 → config 반환 + 캐시", async () => {
|
||||
const cfg = { clientId: "c", authority: "https://a", tenantId: "t" }
|
||||
const { authApi } = await import("@/features/auth/api/auth.api")
|
||||
;(authApi.getEntraConfig as any).mockResolvedValue(cfg)
|
||||
|
||||
const { getEntraConfig } = await import("./msal")
|
||||
expect(await getEntraConfig()).toEqual(cfg)
|
||||
expect(await getEntraConfig()).toEqual(cfg)
|
||||
expect(authApi.getEntraConfig).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("authApi.getEntraConfig null → null 반환", async () => {
|
||||
const { authApi } = await import("@/features/auth/api/auth.api")
|
||||
;(authApi.getEntraConfig as any).mockResolvedValue(null)
|
||||
const { getEntraConfig } = await import("./msal")
|
||||
expect(await getEntraConfig()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("loginWithMicrosoft", () => {
|
||||
it("정상 흐름 → idToken + graphAccessToken 반환", async () => {
|
||||
const cfg = { clientId: "c", authority: "https://a", tenantId: "t" }
|
||||
const { authApi } = await import("@/features/auth/api/auth.api")
|
||||
;(authApi.getEntraConfig as any).mockResolvedValue(cfg)
|
||||
mockLoginPopup.mockResolvedValue({ idToken: "ID_TOKEN", account: { homeAccountId: "h1" } })
|
||||
mockAcquireTokenSilent.mockResolvedValue({ accessToken: "GRAPH_TOKEN" })
|
||||
const { loginWithMicrosoft } = await import("./msal")
|
||||
const result = await loginWithMicrosoft()
|
||||
expect(result).toEqual({ idToken: "ID_TOKEN", graphAccessToken: "GRAPH_TOKEN" })
|
||||
})
|
||||
|
||||
it("acquireTokenSilent 실패 → graphAccessToken=null (swallow)", async () => {
|
||||
const cfg = { clientId: "c", authority: "https://a", tenantId: "t" }
|
||||
const { authApi } = await import("@/features/auth/api/auth.api")
|
||||
;(authApi.getEntraConfig as any).mockResolvedValue(cfg)
|
||||
mockLoginPopup.mockResolvedValue({ idToken: "ID_TOKEN", account: { homeAccountId: "h1" } })
|
||||
mockAcquireTokenSilent.mockRejectedValue(new Error("silent fail"))
|
||||
const { loginWithMicrosoft } = await import("./msal")
|
||||
const result = await loginWithMicrosoft()
|
||||
expect(result).toEqual({ idToken: "ID_TOKEN", graphAccessToken: null })
|
||||
})
|
||||
|
||||
it("loginPopup 실패 → throw", async () => {
|
||||
const cfg = { clientId: "c", authority: "https://a", tenantId: "t" }
|
||||
const { authApi } = await import("@/features/auth/api/auth.api")
|
||||
;(authApi.getEntraConfig as any).mockResolvedValue(cfg)
|
||||
const err: any = new Error("user_cancelled")
|
||||
err.errorCode = "user_cancelled"
|
||||
mockLoginPopup.mockRejectedValue(err)
|
||||
const { loginWithMicrosoft } = await import("./msal")
|
||||
await expect(loginWithMicrosoft()).rejects.toThrow("user_cancelled")
|
||||
})
|
||||
|
||||
it("config null → ENTRA_NOT_CONFIGURED throw", async () => {
|
||||
const { authApi } = await import("@/features/auth/api/auth.api")
|
||||
;(authApi.getEntraConfig as any).mockResolvedValue(null)
|
||||
const { loginWithMicrosoft } = await import("./msal")
|
||||
await expect(loginWithMicrosoft()).rejects.toThrow("ENTRA_NOT_CONFIGURED")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import { PublicClientApplication, type AuthenticationResult } from "@azure/msal-browser"
|
||||
import { authApi } from "@/features/auth/api/auth.api"
|
||||
import { env } from "@/config/env"
|
||||
import type { EntraConfigResponse } from "@/types/api"
|
||||
|
||||
let _msalApp: PublicClientApplication | null = null
|
||||
let _config: EntraConfigResponse | null = null
|
||||
let _configFetched = false
|
||||
|
||||
export async function getEntraConfig(): Promise<EntraConfigResponse | null> {
|
||||
if (_configFetched) return _config
|
||||
_config = await authApi.getEntraConfig()
|
||||
_configFetched = true
|
||||
return _config
|
||||
}
|
||||
|
||||
async function getMsalApp(): Promise<PublicClientApplication> {
|
||||
if (_msalApp) return _msalApp
|
||||
const cfg = await getEntraConfig()
|
||||
if (!cfg) throw new Error("ENTRA_NOT_CONFIGURED")
|
||||
_msalApp = new PublicClientApplication({
|
||||
auth: {
|
||||
clientId: cfg.clientId,
|
||||
authority: cfg.authority,
|
||||
redirectUri: window.location.origin,
|
||||
},
|
||||
cache: { cacheLocation: "sessionStorage" },
|
||||
})
|
||||
await _msalApp.initialize()
|
||||
return _msalApp
|
||||
}
|
||||
|
||||
export interface EntraTokens {
|
||||
idToken: string
|
||||
graphAccessToken: string | null
|
||||
}
|
||||
|
||||
export async function loginWithMicrosoft(): Promise<EntraTokens> {
|
||||
const app = await getMsalApp()
|
||||
const result: AuthenticationResult = await app.loginPopup({
|
||||
scopes: [env.entraGraphScope, "openid", "profile", "email"],
|
||||
// 브라우저에 MS 계정이 이미 로그인돼 있어도 항상 계정 선택/추가 창을 표시.
|
||||
prompt: "select_account",
|
||||
})
|
||||
|
||||
let graphAccessToken: string | null = null
|
||||
try {
|
||||
const silent = await app.acquireTokenSilent({
|
||||
account: result.account!,
|
||||
scopes: [env.entraGraphScope],
|
||||
})
|
||||
graphAccessToken = silent.accessToken
|
||||
} catch (e) {
|
||||
console.warn("[entra] acquireTokenSilent failed, fallback to App Permission", e)
|
||||
}
|
||||
|
||||
return { idToken: result.idToken, graphAccessToken }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, it, expect, afterEach } from "vitest"
|
||||
import { getAccessToken, setAccessToken } from "./tokenProvider"
|
||||
|
||||
afterEach(() => setAccessToken(null))
|
||||
|
||||
describe("tokenProvider", () => {
|
||||
it("기본값은 null (쿠키 모드)", () => {
|
||||
expect(getAccessToken()).toBeNull()
|
||||
})
|
||||
it("set 하면 그 토큰을 돌려준다", () => {
|
||||
setAccessToken("abc")
|
||||
expect(getAccessToken()).toBe("abc")
|
||||
})
|
||||
it("null 로 다시 초기화 가능", () => {
|
||||
setAccessToken("abc")
|
||||
setAccessToken(null)
|
||||
expect(getAccessToken()).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
// .NET 웹뷰 호스트가 주입할 accessToken 의 단일 소스.
|
||||
// 브라우저(오늘)에선 기본 null → 쿠키 인증. 나중에 .NET 이 setAccessToken 으로 채우면
|
||||
// client.ts / sse.ts 가 Authorization: Bearer 로 전환한다.
|
||||
let accessToken: string | null = null
|
||||
|
||||
export function getAccessToken(): string | null {
|
||||
return accessToken
|
||||
}
|
||||
|
||||
export function setAccessToken(token: string | null): void {
|
||||
accessToken = token
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import { clearMocks, mockIPC } from "@tauri-apps/api/mocks"
|
||||
import { emit } from "@tauri-apps/api/event"
|
||||
import {
|
||||
consumePendingCaptureImage,
|
||||
getLastPasteTarget,
|
||||
initBridgeNavigate,
|
||||
setBridgeNavigate,
|
||||
} from "./bridgeNavigate"
|
||||
|
||||
describe("bridgeNavigate", () => {
|
||||
afterEach(() => {
|
||||
setBridgeNavigate(null)
|
||||
clearMocks()
|
||||
})
|
||||
|
||||
it("Tauri bridge payload를 기존 navigate·paste.target·capture.image 분기로 보낸다", async () => {
|
||||
mockIPC(() => undefined, { shouldMockEvents: true })
|
||||
const navigate = vi.fn()
|
||||
const onNavigate = vi.fn()
|
||||
const onPasteTarget = vi.fn()
|
||||
const onCapture = vi.fn()
|
||||
setBridgeNavigate(navigate)
|
||||
window.addEventListener("bridge:navigate", onNavigate)
|
||||
window.addEventListener("bridge:pasteTarget", onPasteTarget)
|
||||
window.addEventListener("bridge:captureImage", onCapture)
|
||||
initBridgeNavigate()
|
||||
initBridgeNavigate()
|
||||
|
||||
await emit("bridge", { type: "navigate", path: "/snippet" })
|
||||
await emit("bridge", { type: "paste.target", name: "메모장", app: "notepad" })
|
||||
await emit("bridge", { type: "capture.image", dataUrl: "data:image/png;base64,abc" })
|
||||
|
||||
expect(navigate).toHaveBeenCalledWith("/snippet")
|
||||
expect(onNavigate).toHaveBeenCalledTimes(1)
|
||||
expect(getLastPasteTarget()).toEqual({ name: "메모장", app: "notepad" })
|
||||
expect(onPasteTarget).toHaveBeenCalledTimes(1)
|
||||
expect(onCapture).toHaveBeenCalledTimes(1)
|
||||
expect(consumePendingCaptureImage()).toBe("data:image/png;base64,abc")
|
||||
expect(consumePendingCaptureImage()).toBe("")
|
||||
|
||||
window.removeEventListener("bridge:navigate", onNavigate)
|
||||
window.removeEventListener("bridge:pasteTarget", onPasteTarget)
|
||||
window.removeEventListener("bridge:captureImage", onCapture)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { listen } from "./transport"
|
||||
|
||||
type NavigateFn = (path: string) => void
|
||||
|
||||
export interface PasteTarget {
|
||||
name: string
|
||||
app: string
|
||||
}
|
||||
|
||||
let navigateFn: NavigateFn | null = null
|
||||
let lastPasteTarget: PasteTarget = { name: "", app: "" }
|
||||
let pendingCaptureImage = ""
|
||||
|
||||
/** 앱 루트에서 useNavigate() 감싼 콜백을 등록함. */
|
||||
export function setBridgeNavigate(fn: NavigateFn | null) {
|
||||
navigateFn = fn
|
||||
}
|
||||
|
||||
/** 마지막 붙여넣기 대상 스냅샷을 돌려줌. */
|
||||
export function getLastPasteTarget(): PasteTarget {
|
||||
return lastPasteTarget
|
||||
}
|
||||
|
||||
/** 마지막 캡처 이미지를 한 번만 소비함. */
|
||||
export function consumePendingCaptureImage(): string {
|
||||
const dataUrl = pendingCaptureImage
|
||||
pendingCaptureImage = ""
|
||||
return dataUrl
|
||||
}
|
||||
|
||||
function handleBridgeMessage(data: {
|
||||
type: string
|
||||
path?: string
|
||||
name?: string
|
||||
app?: string
|
||||
dataUrl?: string
|
||||
}) {
|
||||
if (data.type === "paste.target") {
|
||||
lastPasteTarget = { name: data.name ?? "", app: data.app ?? "" }
|
||||
window.dispatchEvent(new CustomEvent("bridge:pasteTarget", { detail: lastPasteTarget }))
|
||||
return
|
||||
}
|
||||
|
||||
if (data.type === "capture.image") {
|
||||
pendingCaptureImage = data.dataUrl ?? ""
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("bridge:captureImage", { detail: { dataUrl: pendingCaptureImage } })
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (data.type !== "navigate" || !data.path) return
|
||||
navigateFn?.(data.path)
|
||||
window.dispatchEvent(new CustomEvent("bridge:navigate", { detail: { path: data.path } }))
|
||||
}
|
||||
|
||||
/** 현재 데스크톱 transport의 push 메시지를 앱 이벤트로 바꿈. */
|
||||
export function initBridgeNavigate() {
|
||||
listen(handleBridgeMessage)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import { clearMocks, mockIPC } from "@tauri-apps/api/mocks"
|
||||
import { emit } from "@tauri-apps/api/event"
|
||||
import { hideWindow, reportRoute } from "./webviewBridge"
|
||||
import { initBridgeNavigate, setBridgeNavigate } from "./bridgeNavigate"
|
||||
|
||||
afterEach(() => {
|
||||
clearMocks()
|
||||
setBridgeNavigate(null)
|
||||
delete (window as unknown as { chrome?: unknown }).chrome
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe("Tauri 단축키 연결", () => {
|
||||
it("현재 대화 위치 보고와 Esc 숨김을 Rust로 보냄", () => {
|
||||
const ipc = vi.fn()
|
||||
mockIPC(ipc)
|
||||
reportRoute("/snap/session/123")
|
||||
hideWindow()
|
||||
expect(ipc).toHaveBeenCalledWith("report_route", { path: "/snap/session/123" })
|
||||
expect(ipc).toHaveBeenCalledWith("window_hide", {})
|
||||
})
|
||||
|
||||
it("화면 이동과 같은 스니펫 재소환 신호를 매번 전달하고 중복 구독하지 않음", async () => {
|
||||
mockIPC(() => {}, { shouldMockEvents: true })
|
||||
const addEventListener = vi.fn()
|
||||
;(window as unknown as { chrome?: unknown }).chrome = {
|
||||
webview: { postMessage: vi.fn(), addEventListener },
|
||||
}
|
||||
const navigate = vi.fn()
|
||||
const summon = vi.fn()
|
||||
window.addEventListener("bridge:navigate", summon)
|
||||
setBridgeNavigate(navigate)
|
||||
initBridgeNavigate()
|
||||
initBridgeNavigate()
|
||||
// listen 등록 Promise가 완료된 뒤 네이티브 푸시를 보냄.
|
||||
await Promise.resolve()
|
||||
await emit("bridge", { type: "navigate", path: "/snippet" })
|
||||
await emit("bridge", { type: "navigate", path: "/snippet" })
|
||||
await emit("bridge", { type: "navigate", path: "/snap/session/123" })
|
||||
expect(navigate.mock.calls).toEqual([["/snippet"], ["/snippet"], ["/snap/session/123"]])
|
||||
expect(summon).toHaveBeenCalledTimes(3)
|
||||
expect(addEventListener).not.toHaveBeenCalled()
|
||||
window.removeEventListener("bridge:navigate", summon)
|
||||
})
|
||||
|
||||
it("Windows Tauri의 chrome.webview를 닷넷으로 오인하지 않음", () => {
|
||||
const ipc = vi.fn()
|
||||
mockIPC(ipc)
|
||||
const postMessage = vi.fn()
|
||||
;(window as unknown as { chrome?: unknown }).chrome = { webview: { postMessage } }
|
||||
hideWindow()
|
||||
expect(postMessage).not.toHaveBeenCalled()
|
||||
expect(ipc).toHaveBeenCalledWith("window_hide", {})
|
||||
})
|
||||
|
||||
it("Tauri가 없는 닷넷에서는 기존 메시지 통로를 사용", () => {
|
||||
const postMessage = vi.fn()
|
||||
;(window as unknown as { chrome?: unknown }).chrome = { webview: { postMessage } }
|
||||
hideWindow()
|
||||
expect(postMessage).toHaveBeenCalledWith({ type: "window.hide" })
|
||||
})
|
||||
|
||||
it("일반 브라우저에서는 창 제어를 보내지 않음", () => {
|
||||
mockIPC(() => {})
|
||||
clearMocks()
|
||||
expect(hideWindow()).toBe(false)
|
||||
expect(reportRoute("/snap")).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest"
|
||||
import { request } from "./snippetBridge"
|
||||
import { clearMocks, mockIPC } from "@tauri-apps/api/mocks"
|
||||
|
||||
type Listener = (e: MessageEvent) => void
|
||||
|
||||
function mockWebview() {
|
||||
const postMessage = vi.fn()
|
||||
let listener: Listener | undefined
|
||||
;(window as unknown as { chrome?: unknown }).chrome = {
|
||||
webview: {
|
||||
postMessage,
|
||||
addEventListener: (type: string, cb: Listener) => {
|
||||
if (type === "message") listener = cb
|
||||
},
|
||||
},
|
||||
}
|
||||
return {
|
||||
postMessage,
|
||||
emit: (data: unknown) => listener?.({ data } as MessageEvent),
|
||||
}
|
||||
}
|
||||
|
||||
describe("snippetBridge.request", () => {
|
||||
afterEach(() => {
|
||||
clearMocks()
|
||||
delete (window as unknown as { chrome?: unknown }).chrome
|
||||
})
|
||||
|
||||
it("Windows Tauri에서는 Rust 목록 응답을 반환하고 닷넷 통로는 쓰지 않음", async () => {
|
||||
const wv = mockWebview()
|
||||
const rows = [{ name: "A", body: "한글 본문", category: "코드", usageCount: 3 }]
|
||||
const ipc = vi.fn().mockResolvedValue(rows)
|
||||
mockIPC(ipc)
|
||||
await expect(request("snippets.list")).resolves.toEqual(rows)
|
||||
expect(ipc).toHaveBeenCalledWith("snippets_list", {})
|
||||
expect(wv.postMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("Tauri 실패 문자열을 Error로 변환하고 사용기록 명령 이름을 연결", async () => {
|
||||
const ipc = vi.fn().mockRejectedValue("DB 열기 실패")
|
||||
mockIPC(ipc)
|
||||
await expect(request("snippets.recordUse", { name: "A" })).rejects.toThrow("DB 열기 실패")
|
||||
expect(ipc).toHaveBeenCalledWith("snippets_record_use", { name: "A" })
|
||||
})
|
||||
|
||||
it("매칭되는 snippets.result(ok:true)가 오면 resolve 됨", async () => {
|
||||
const wv = mockWebview()
|
||||
const promise = request("snippets.list")
|
||||
|
||||
expect(wv.postMessage).toHaveBeenCalledTimes(1)
|
||||
const sent = wv.postMessage.mock.calls[0][0] as { type: string; reqId: string }
|
||||
expect(sent.type).toBe("snippets.list")
|
||||
expect(sent.reqId).toBeTruthy()
|
||||
|
||||
wv.emit({ type: "snippets.result", reqId: sent.reqId, ok: true, data: [{ name: "a" }] })
|
||||
|
||||
await expect(promise).resolves.toEqual([{ name: "a" }])
|
||||
})
|
||||
|
||||
it("ok:false 면 에러 메시지로 reject 됨", async () => {
|
||||
const wv = mockWebview()
|
||||
const promise = request("snippets.delete", { name: "x" })
|
||||
const sent = wv.postMessage.mock.calls[0][0] as { reqId: string }
|
||||
|
||||
wv.emit({ type: "snippets.result", reqId: sent.reqId, ok: false, error: "중복된 이름" })
|
||||
|
||||
await expect(promise).rejects.toThrow("중복된 이름")
|
||||
})
|
||||
|
||||
it("reqId가 다른 응답은 무시하고 상관없는 type도 무시함", async () => {
|
||||
const wv = mockWebview()
|
||||
const promise = request("snippets.list")
|
||||
const sent = wv.postMessage.mock.calls[0][0] as { reqId: string }
|
||||
|
||||
wv.emit({ type: "navigate", path: "/snippet" }) // 다른 리스너 몫 — 무시돼야 함
|
||||
wv.emit({ type: "snippets.result", reqId: "다른reqId", ok: true, data: [] }) // 매칭 안 됨 — 무시
|
||||
wv.emit({ type: "snippets.result", reqId: sent.reqId, ok: true, data: "ok" })
|
||||
|
||||
await expect(promise).resolves.toBe("ok")
|
||||
})
|
||||
|
||||
it("웹뷰 밖(순수 브라우저)이면 hang 없이 reject 됨", async () => {
|
||||
await expect(request("snippets.list")).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,2 @@
|
||||
// 소비자 import 경로는 유지하고 호스트 선택·왕복 처리는 transport 한 곳에서 맡음.
|
||||
export { request } from "./transport"
|
||||
@@ -0,0 +1,129 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import { clearMocks, mockIPC } from "@tauri-apps/api/mocks"
|
||||
import { hostKind, listen, request, send } from "./transport"
|
||||
import { toast } from "sonner"
|
||||
|
||||
vi.mock("sonner", () => ({ toast: { error: vi.fn() } }))
|
||||
|
||||
type WebViewListener = (event: MessageEvent) => void
|
||||
|
||||
interface HostWindow extends Window {
|
||||
chrome?: {
|
||||
webview: {
|
||||
postMessage: (message: unknown) => void
|
||||
addEventListener: (type: string, callback: WebViewListener) => void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// jsdom Window에 데스크톱 호스트가 런타임 주입하는 필드만 보탠 테스트 경계임.
|
||||
const hostWindow = window as HostWindow
|
||||
|
||||
function mockWebView2() {
|
||||
const postMessage = vi.fn<(message: unknown) => void>()
|
||||
let listener: WebViewListener | undefined
|
||||
hostWindow.chrome = {
|
||||
webview: {
|
||||
postMessage,
|
||||
addEventListener: (type, callback) => {
|
||||
if (type === "message") listener = callback
|
||||
},
|
||||
},
|
||||
}
|
||||
return {
|
||||
postMessage,
|
||||
emit: (data: unknown) => listener?.({ data } as MessageEvent),
|
||||
}
|
||||
}
|
||||
|
||||
describe("bridge transport", () => {
|
||||
afterEach(() => {
|
||||
clearMocks()
|
||||
delete hostWindow.chrome
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("Tauri invoke가 있으면 WebView2보다 먼저 고른다", () => {
|
||||
mockWebView2()
|
||||
mockIPC(() => undefined)
|
||||
|
||||
expect(hostKind()).toBe("tauri")
|
||||
})
|
||||
|
||||
it("chrome.webview만 있으면 WebView2를 고른다", () => {
|
||||
mockWebView2()
|
||||
|
||||
expect(hostKind()).toBe("webview2")
|
||||
})
|
||||
|
||||
it("데스크톱 통로가 없으면 browser를 고른다", () => {
|
||||
expect(hostKind()).toBe("browser")
|
||||
})
|
||||
|
||||
it("browser 단방향 전송은 no-op이고 응답 요청은 바로 거부한다", async () => {
|
||||
expect(send({ type: "window.hide" })).toBe(false)
|
||||
await expect(request("snippets.list")).rejects.toThrow("데스크톱 전용")
|
||||
})
|
||||
|
||||
it("Tauri 메시지 이름을 command 이름으로 바꾸고 payload를 넘긴다", async () => {
|
||||
const calls: Array<{ command: string; payload?: unknown }> = []
|
||||
mockIPC((command, payload) => {
|
||||
calls.push({ command, payload })
|
||||
return { name: "FOO", usageCount: 1, lastUsed: 1 }
|
||||
})
|
||||
|
||||
await request("snippets.recordUse", { name: "FOO" })
|
||||
send({ type: "route.changed", path: "/snap" })
|
||||
await Promise.resolve()
|
||||
|
||||
expect(calls).toContainEqual({ command: "snippets_record_use", payload: { name: "FOO" } })
|
||||
expect(calls).toContainEqual({ command: "report_route", payload: { path: "/snap" } })
|
||||
})
|
||||
|
||||
it("Tauri invoke 오류 문자열을 Error로 올린다", async () => {
|
||||
mockIPC(() => Promise.reject("아직 안 됨"))
|
||||
|
||||
await expect(request("snippets.list")).rejects.toEqual(new Error("아직 안 됨"))
|
||||
})
|
||||
|
||||
it("Tauri 창 제어 실패는 사용자에게 오류를 표시한다", async () => {
|
||||
mockIPC(() => Promise.reject("창 숨김 실패"))
|
||||
|
||||
expect(send({ type: "window.hide" })).toBe(true)
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(toast.error).toHaveBeenCalledWith("데스크톱 요청 실패: 창 숨김 실패")
|
||||
)
|
||||
})
|
||||
|
||||
it("WebView2 요청은 reqId가 맞는 응답만 돌려준다", async () => {
|
||||
const webview = mockWebView2()
|
||||
const promise = request<{ name: string }>("snippets.create", {
|
||||
snippet: { name: "FOO" },
|
||||
})
|
||||
const sent = webview.postMessage.mock.calls[0]?.[0]
|
||||
if (!sent || typeof sent !== "object" || !("reqId" in sent) || typeof sent.reqId !== "string") {
|
||||
throw new Error("WebView2 요청에 reqId가 없음")
|
||||
}
|
||||
|
||||
webview.emit({ type: "snippets.result", reqId: "다른 값", ok: true, data: {} })
|
||||
webview.emit({
|
||||
type: "snippets.result",
|
||||
reqId: sent.reqId,
|
||||
ok: true,
|
||||
data: { name: "FOO" },
|
||||
})
|
||||
|
||||
await expect(promise).resolves.toEqual({ name: "FOO" })
|
||||
})
|
||||
|
||||
it("WebView2와 browser listener는 같은 payload 표면을 쓴다", () => {
|
||||
const webview = mockWebView2()
|
||||
const handler = vi.fn()
|
||||
listen(handler)
|
||||
|
||||
webview.emit({ type: "navigate", path: "/snippet" })
|
||||
|
||||
expect(handler).toHaveBeenCalledWith({ type: "navigate", path: "/snippet" })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,174 @@
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
import { listen as listenTauri } from "@tauri-apps/api/event"
|
||||
import { z } from "zod"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export type HostKind = "tauri" | "webview2" | "browser"
|
||||
|
||||
export type HostMessage = { type: string } & Record<string, unknown>
|
||||
|
||||
type WebView2 = {
|
||||
postMessage: (message: unknown) => void
|
||||
addEventListener: (type: "message", callback: (event: MessageEvent<unknown>) => void) => void
|
||||
}
|
||||
|
||||
interface HostWindow extends Window {
|
||||
__TAURI_INTERNALS__?: { invoke?: unknown }
|
||||
chrome?: { webview?: WebView2 }
|
||||
}
|
||||
|
||||
const BridgeMessageSchema = z
|
||||
.object({
|
||||
type: z.string(),
|
||||
path: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
app: z.string().optional(),
|
||||
dataUrl: z.string().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
export type BridgeMessage = z.infer<typeof BridgeMessageSchema>
|
||||
type HostListener = (message: BridgeMessage) => void
|
||||
const SnippetsResultSchema = z
|
||||
.object({
|
||||
type: z.literal("snippets.result"),
|
||||
reqId: z.string(),
|
||||
ok: z.boolean(),
|
||||
data: z.unknown().optional(),
|
||||
error: z.string().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
// Tauri와 WebView2가 런타임 주입하는 필드만 보탠 경계 타입임.
|
||||
const hostWindow = window as HostWindow
|
||||
|
||||
let requestSequence = 0
|
||||
const pendingRequests = new Map<
|
||||
string,
|
||||
{ resolve: (data: unknown) => void; reject: (error: Error) => void }
|
||||
>()
|
||||
let requestListenerTarget: WebView2 | undefined
|
||||
const tauriHandlers = new Set<HostListener>()
|
||||
const webView2Handlers = new WeakMap<WebView2, Set<HostListener>>()
|
||||
let tauriListenerStarted = false
|
||||
|
||||
export function hostKind(): HostKind {
|
||||
if (typeof hostWindow.__TAURI_INTERNALS__?.invoke === "function") return "tauri"
|
||||
if (hostWindow.chrome?.webview) return "webview2"
|
||||
return "browser"
|
||||
}
|
||||
|
||||
function webView2(): WebView2 | undefined {
|
||||
return hostWindow.chrome?.webview
|
||||
}
|
||||
|
||||
function commandName(type: string): string {
|
||||
if (type === "route.changed") return "report_route"
|
||||
return type.replaceAll(".", "_").replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`)
|
||||
}
|
||||
|
||||
function commandPayload(message: HostMessage): Record<string, unknown> {
|
||||
const payload: Record<string, unknown> = { ...message }
|
||||
delete payload.type
|
||||
return payload
|
||||
}
|
||||
|
||||
function toError(error: unknown): Error {
|
||||
if (error instanceof Error) return error
|
||||
if (typeof error === "string") return new Error(error)
|
||||
return new Error("데스크톱 요청에 실패함")
|
||||
}
|
||||
|
||||
function ensureWebView2RequestListener(webview: WebView2) {
|
||||
if (requestListenerTarget === webview) return
|
||||
requestListenerTarget = webview
|
||||
webview.addEventListener("message", (event) => {
|
||||
const parsed = SnippetsResultSchema.safeParse(event.data)
|
||||
if (!parsed.success) return
|
||||
|
||||
const entry = pendingRequests.get(parsed.data.reqId)
|
||||
if (!entry) return
|
||||
pendingRequests.delete(parsed.data.reqId)
|
||||
if (parsed.data.ok) entry.resolve(parsed.data.data)
|
||||
else entry.reject(new Error(parsed.data.error ?? "알 수 없는 오류"))
|
||||
})
|
||||
}
|
||||
|
||||
export function send(message: HostMessage): boolean {
|
||||
const kind = hostKind()
|
||||
if (kind === "tauri") {
|
||||
void invoke(commandName(message.type), commandPayload(message)).catch((error) => {
|
||||
toast.error(`데스크톱 요청 실패: ${toError(error).message}`)
|
||||
})
|
||||
return true
|
||||
}
|
||||
if (kind === "webview2") {
|
||||
webView2()?.postMessage(message)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export async function request<T = unknown>(
|
||||
type: string,
|
||||
payload: Record<string, unknown> = {}
|
||||
): Promise<T> {
|
||||
const kind = hostKind()
|
||||
if (kind === "tauri") {
|
||||
try {
|
||||
return await invoke<T>(commandName(type), payload)
|
||||
} catch (error) {
|
||||
throw toError(error)
|
||||
}
|
||||
}
|
||||
|
||||
const webview = webView2()
|
||||
if (kind === "webview2" && webview) {
|
||||
ensureWebView2RequestListener(webview)
|
||||
const reqId = String(++requestSequence)
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
pendingRequests.set(reqId, {
|
||||
resolve: resolve as (data: unknown) => void,
|
||||
reject,
|
||||
})
|
||||
webview.postMessage({ type, reqId, ...payload })
|
||||
})
|
||||
}
|
||||
|
||||
throw new Error("desktop-only: 데스크톱 전용 기능임")
|
||||
}
|
||||
|
||||
export function listen(handler: HostListener): void {
|
||||
const kind = hostKind()
|
||||
if (kind === "tauri") {
|
||||
tauriHandlers.add(handler)
|
||||
if (tauriListenerStarted) return
|
||||
tauriListenerStarted = true
|
||||
void listenTauri<unknown>("bridge", (event) => {
|
||||
const parsed = BridgeMessageSchema.safeParse(event.payload)
|
||||
if (!parsed.success) return
|
||||
for (const currentHandler of tauriHandlers) currentHandler(parsed.data)
|
||||
}).catch((error) => {
|
||||
tauriListenerStarted = false
|
||||
toast.error(`단축키 화면 이동 연결 실패: ${toError(error).message}`)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (kind !== "webview2") return
|
||||
const webview = webView2()
|
||||
if (!webview) return
|
||||
|
||||
let handlers = webView2Handlers.get(webview)
|
||||
if (!handlers) {
|
||||
handlers = new Set()
|
||||
webView2Handlers.set(webview, handlers)
|
||||
webview.addEventListener("message", (event) => {
|
||||
const parsed = BridgeMessageSchema.safeParse(event.data)
|
||||
if (!parsed.success) return
|
||||
for (const currentHandler of webView2Handlers.get(webview) ?? []) {
|
||||
currentHandler(parsed.data)
|
||||
}
|
||||
})
|
||||
}
|
||||
handlers.add(handler)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { hostKind, send } from "./transport"
|
||||
|
||||
/** 데스크톱 호스트 안에서 실행 중이면 true. 기존 소비자 이름은 유지함. */
|
||||
export function isWebView(): boolean {
|
||||
return hostKind() !== "browser"
|
||||
}
|
||||
|
||||
/** 데스크톱 창 숨김(트레이로 내림). */
|
||||
export const hideWindow = () => send({ type: "window.hide" })
|
||||
|
||||
/** 코드를 소환 직전 앱에 붙여넣음. */
|
||||
export const pasteToApp = (text: string) => send({ type: "paste.code", text })
|
||||
|
||||
/** 현재 React route를 데스크톱 호스트에 알림. */
|
||||
export const reportRoute = (path: string) => send({ type: "route.changed", path })
|
||||
|
||||
/** 프레임리스 창을 native 창 이동으로 끌 수 있게 함. */
|
||||
export const startWindowDrag = () => send({ type: "window.drag" })
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
export function useDebounce<T>(value: T, delay = 300): T {
|
||||
const [debounced, setDebounced] = useState(value)
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebounced(value), delay)
|
||||
return () => clearTimeout(t)
|
||||
}, [value, delay])
|
||||
|
||||
return debounced
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { QueryClient } from "@tanstack/react-query"
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
retry: (failureCount, error: unknown) => {
|
||||
// 401·403·404는 재시도 안 함
|
||||
const status = (error as { status?: number })?.status
|
||||
if (status && [401, 403, 404].includes(status)) return false
|
||||
return failureCount < 2
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,447 @@
|
||||
# `lib/streaming` — LLM 스트리밍 풀스택 템플릿
|
||||
|
||||
LLM SSE 스트리밍을 **백엔드 contract → 프론트 어댑터 → typewriter 렌더링 → 중단 버튼** 한 묶음으로 묶은 재사용 모듈. 폴더 통째 복사하면 다음 프로젝트에 그대로 이식 가능.
|
||||
|
||||
## 한눈에
|
||||
|
||||
```tsx
|
||||
import {
|
||||
streamLLM, // 표준 event 어댑터
|
||||
StreamingText, // typewriter UI
|
||||
useStreamSession, // stop 버튼용 hook
|
||||
} from "@/lib/streaming"
|
||||
|
||||
function ChatBox() {
|
||||
const { run, stop, isRunning } = useStreamSession()
|
||||
const [text, setText] = useState("")
|
||||
|
||||
const submit = (q: string) => {
|
||||
setText("")
|
||||
void run((signal) =>
|
||||
streamLLM({
|
||||
path: "/chat/stream",
|
||||
body: { messages: [{ role: "user", content: q }] },
|
||||
signal,
|
||||
handlers: {
|
||||
onToken: (delta) => setText((t) => t + delta),
|
||||
onDone: () => {},
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<StreamingText text={text} isStreaming={isRunning} />
|
||||
{isRunning ? (
|
||||
<button onClick={stop}>중단</button>
|
||||
) : (
|
||||
<button onClick={() => submit(input)}>보내기</button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
이게 다임. 아래는 모듈별 자세한 설명.
|
||||
|
||||
---
|
||||
|
||||
## 패턴 모음 — 다음 프로젝트에 그대로 베껴 쓰기
|
||||
|
||||
### 패턴 A — 단일 응답 (한 번에 한 텍스트)
|
||||
|
||||
위 "한눈에" 코드. `useState<string>("")`에 토큰 누적, `<StreamingText text={...} isStreaming={isRunning} />`. 가장 단순.
|
||||
|
||||
### 패턴 B — 메시지 이력 chat (user/assistant 누적)
|
||||
|
||||
채팅창처럼 메시지가 쌓이는데, **진행 중인 마지막 assistant 메시지에만 typewriter** 적용해야 함. 과거 메시지는 mount될 때 즉시 다 보여야 함.
|
||||
|
||||
핵심 트릭 — store에는 메시지별 `isStreaming` 플래그를 안 두고, **전역 `isStreaming` 하나만** 두고 List에서 마지막 메시지에만 prop으로 넘김:
|
||||
|
||||
```ts
|
||||
// store
|
||||
interface ChatState {
|
||||
messages: Array<{ id: string; role: "user" | "assistant"; content: string }>
|
||||
isStreaming: boolean // 전역 하나
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// MessageList.tsx
|
||||
const { messages, isStreaming } = useChatStore()
|
||||
const lastIdx = messages.length - 1
|
||||
const lastIsStreaming = isStreaming && lastIdx >= 0 && messages[lastIdx].role === "assistant"
|
||||
|
||||
return messages.map((m, i) => (
|
||||
<MessageBubble key={m.id} message={m} isStreaming={lastIsStreaming && i === lastIdx} />
|
||||
))
|
||||
```
|
||||
|
||||
```tsx
|
||||
// MessageBubble.tsx — assistant 분기
|
||||
<StreamingText text={message.content} isStreaming={isStreaming} />
|
||||
```
|
||||
|
||||
`useSmoothedText`는 `active=false`로 mount되면 `useState` 초기값으로 즉시 full 텍스트 표시 — 과거 메시지가 다시 타이핑되는 일 없음. 이 동작에 의존해서 per-message 플래그 없이 깔끔히 분리됨.
|
||||
|
||||
이 프로젝트의 `2_frontend/src/features/chat/` 가 이 패턴 그대로.
|
||||
|
||||
### 패턴 C — 사전 결과 + LLM 요약 (result event 사용)
|
||||
|
||||
조회 결과(표 등) 먼저 보여주고 그 위에 LLM이 요약 다는 케이스. 백엔드는 `result` event 한 번 + `token` event N번. 프론트는 `onResult`/`onToken` 둘 다 핸들링.
|
||||
|
||||
```ts
|
||||
await streamLLM<{ items: Foo[] }>({
|
||||
path: "/sap/lookup/stream",
|
||||
body: { tableName },
|
||||
signal,
|
||||
handlers: {
|
||||
onResult: ({ items }) => store.setItems(items), // 표 데이터
|
||||
onToken: (delta) => store.appendSummary(delta), // 요약 누적
|
||||
onDone: () => store.finish(),
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
UI 렌더 순서: 결과표 → `<StreamingText text={summary} isStreaming={...} />`.
|
||||
|
||||
### 어느 패턴 쓰지
|
||||
|
||||
| 케이스 | 패턴 |
|
||||
| -------------------------------------- | ---- |
|
||||
| 검색창 한 번 누르면 LLM 답변 한 덩어리 | A |
|
||||
| ChatGPT 같은 대화창 (메시지 이력) | B |
|
||||
| 도구 결과 + LLM 코멘트 | C |
|
||||
|
||||
세 패턴 모두 **같은 backend contract**(`event: result/token/done/error`)를 씀. 백엔드가 보내는 event 종류만 다름:
|
||||
|
||||
- A/B: `token` × N + `done`
|
||||
- C: `result` × 1 + `token` × N + `done`
|
||||
|
||||
---
|
||||
|
||||
## 1. typewriter 렌더링 — `StreamingText` / `useSmoothedText`
|
||||
|
||||
LLM 토큰은 네트워크 청크에 묶여 들쭉날쭉 도착함. `setText(prev + delta)` 식으로 그대로 그리면 뭉텅이로 한 번에 훅 뿌려져서 어색함. 이 모듈은:
|
||||
|
||||
- 받은 텍스트를 **버퍼**에 두고 화면엔 일정 페이스로 한 글자씩 흘림 (typewriter)
|
||||
- 토큰이 뭉텅이로 도착하면 살짝 **가속**해서 따라잡음 (한 글자씩은 유지)
|
||||
- 백엔드가 `done` 보내도 **받은 만큼은 마저 다 찍은 뒤** typewriter 종료
|
||||
- 흘러나온 텍스트는 **react-markdown + remark-gfm** 으로 렌더 — `**굵게**`, 리스트, 표, 코드블록, 링크, 체크리스트 등 GFM 전부 지원
|
||||
- Tailwind 클래스는 `StreamingText.tsx` 의 `MD_COMPONENTS` 매핑으로 주입 — 챗 버블 톤에 맞게 단순한 스타일만 입힘
|
||||
|
||||
### 빠르게 쓰기
|
||||
|
||||
```tsx
|
||||
<StreamingText
|
||||
text={message.summary} // 누적 텍스트 (토큰 누적값)
|
||||
isStreaming={message.isStreaming}
|
||||
/>
|
||||
```
|
||||
|
||||
### 페이스 튜닝
|
||||
|
||||
기본값(`baseCps: 8` ≈ 125ms당 1글자)이 사람 편한 속도. 빠르게 하고 싶으면:
|
||||
|
||||
```tsx
|
||||
<StreamingText text={...} isStreaming={...} cps={{ baseCps: 16, maxCps: 80 }} />
|
||||
```
|
||||
|
||||
| `baseCps` | 1글자 간격 | 느낌 |
|
||||
| --------- | ---------- | ------------- |
|
||||
| 6 | 167ms | 매우 또박또박 |
|
||||
| 8 | 125ms | 기본 — 편함 |
|
||||
| 12 | 83ms | 살짝 빠릿 |
|
||||
| 20 | 50ms | ChatGPT 비슷 |
|
||||
| 30+ | 33ms 이하 | 빠름 |
|
||||
|
||||
### hook 단독 사용
|
||||
|
||||
자체 렌더러 짤 때:
|
||||
|
||||
```tsx
|
||||
import { useSmoothedText, Caret } from "@/lib/streaming"
|
||||
|
||||
function MyBubble({ text, streaming }) {
|
||||
const { text: shown, revealing } = useSmoothedText(text, streaming)
|
||||
return (
|
||||
<p>
|
||||
{shown}
|
||||
{revealing && <Caret />}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 마크다운 렌더링 커스터마이즈
|
||||
|
||||
태그별 스타일은 `StreamingText.tsx` 의 `MD_COMPONENTS` 객체에서 직접 수정. 예: 링크에 다른 색 입히고 싶으면 `a` 키를 고치면 됨. 새 태그가 필요하면 그 키를 추가.
|
||||
|
||||
### 핵심 트릭 — 왜 부드럽나
|
||||
|
||||
소수점 accumulator 패턴:
|
||||
|
||||
```ts
|
||||
accumulator += (cps * dt) / 1000 // 매 프레임 누적
|
||||
const reveal = Math.floor(accumulator)
|
||||
accumulator -= reveal
|
||||
```
|
||||
|
||||
`Math.max(1, Math.round(...))` 같이 **억지로 매 프레임 1글자** 강제하면 BASE_CPS 설정값과 무관하게 60 cps로 돌아감(들쭉날쭉). accumulator 패턴은 진짜로 일정 간격 유지됨.
|
||||
|
||||
또 하나: 스트리밍 끝나도(`isStreaming: false`) hook은 그걸 안 봄. RAF는 **`displayed.length >= full.length`** 만 보고 돌아서, 받은 글자 다 찍어야 비로소 멈춤. 그래서 끝에서 훅 뿌리는 일이 없음.
|
||||
|
||||
---
|
||||
|
||||
## 2. SSE 어댑터 — `streamLLM`
|
||||
|
||||
표준 event contract(`event: result/token/done/error`)를 타입 있는 핸들러로 매핑. 매번 `JSON.parse(e.data)` + `if (e.event === ...)` 보일러플레이트 안 짜도 됨.
|
||||
|
||||
### 사용 예
|
||||
|
||||
```ts
|
||||
import { streamLLM } from "@/lib/streaming"
|
||||
|
||||
await streamLLM<{ items: Foo[] }>({
|
||||
path: "/chat/sap/cds-view-finder/stream",
|
||||
body: { tableName: "MARA" },
|
||||
signal: ctrl.signal,
|
||||
handlers: {
|
||||
onResult: ({ items }) => store.setItems(items),
|
||||
onToken: (delta) => store.appendToken(delta),
|
||||
onDone: () => store.finish(),
|
||||
onError: (e) => store.fail(e.message),
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Event contract (백엔드 ↔ 프론트 약속)
|
||||
|
||||
| event | data 모양 | 횟수 | 의미 |
|
||||
| -------- | -------------------------------- | ---- | ------------------------------- |
|
||||
| `result` | 임의의 JSON 객체 | 0~1 | 토큰 시작 전 사전 데이터 (선택) |
|
||||
| `token` | `{"delta":"..."}` | 0~N | 토큰 한 조각 |
|
||||
| `done` | `{}` | 1 | 정상 종료 |
|
||||
| `error` | `{"message":"...","code":"..."}` | 0~1 | 오류 종료 |
|
||||
|
||||
- `result`는 옵셔널 — 단순 챗 스트림은 token만 보내도 됨
|
||||
- malformed JSON 한 토큰은 무시하고 다음으로 진행 (회복 친화)
|
||||
- 알 수 없는 event 타입은 무시 (확장 친화)
|
||||
|
||||
### 저수준 — `streamSSE`
|
||||
|
||||
generic SSE가 필요하면 (다른 contract):
|
||||
|
||||
```ts
|
||||
import { streamSSE } from "@/lib/streaming"
|
||||
|
||||
await streamSSE({
|
||||
path: "/some/sse",
|
||||
body: {...},
|
||||
onEvent: (e) => console.log(e.event, e.data),
|
||||
})
|
||||
```
|
||||
|
||||
`@microsoft/fetch-event-source` 위에 401 자동 refresh + abort 지원만 추가한 얇은 wrapper.
|
||||
|
||||
---
|
||||
|
||||
## 3. AbortController 통합 — `useStreamSession` / `isAbortError`
|
||||
|
||||
스트리밍 중간에 사용자가 "중단" 누를 수 있어야 함. `AbortController` 표준 + 이 모듈의 헬퍼로 깔끔히 처리.
|
||||
|
||||
### 컴포넌트 로컬 — `useStreamSession` hook
|
||||
|
||||
가장 흔한 케이스. controller 생성·관리·언마운트 시 자동 abort까지 다 해줌:
|
||||
|
||||
```tsx
|
||||
const { run, stop, isRunning } = useStreamSession()
|
||||
|
||||
const submit = (q: string) =>
|
||||
void run(async (signal) => {
|
||||
await streamLLM({ path: "...", body: {...}, signal, handlers: {...} })
|
||||
})
|
||||
|
||||
return isRunning ? (
|
||||
<button onClick={stop}>중단</button>
|
||||
) : (
|
||||
<button onClick={() => submit(input)}>보내기</button>
|
||||
)
|
||||
```
|
||||
|
||||
- 새 `run()` 호출 시 이전 진행 중인 스트림 자동 취소 (사용자가 새 쿼리 보낸 경우)
|
||||
- 컴포넌트 언마운트 시 진행 중인 스트림 자동 abort (메모리/네트워크 누수 방지)
|
||||
- `run()` 반환값: `{ ok: true, value }` 또는 `{ ok: false, aborted, error }` — abort vs 진짜 에러 구분
|
||||
|
||||
### 글로벌 store 패턴 (zustand 등)
|
||||
|
||||
store에 currentController를 들고 다니는 경우. 이 모듈은 `isAbortError(e)` 헬퍼만 제공하고 store 구조는 직접 짜는 게 자연스러움:
|
||||
|
||||
```ts
|
||||
import { isAbortError } from "@/lib/streaming"
|
||||
|
||||
interface State {
|
||||
currentController: AbortController | null
|
||||
ask: (q: string) => Promise<void>
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
const useStore = create<State>((set, get) => ({
|
||||
currentController: null,
|
||||
ask: async (q) => {
|
||||
const ctrl = new AbortController()
|
||||
set({ currentController: ctrl })
|
||||
try {
|
||||
await streamLLM({ path: "...", body: { q }, signal: ctrl.signal, handlers: {...} })
|
||||
} catch (e) {
|
||||
// abort는 정상 종료처럼 처리 (에러 버블 만들지 않음)
|
||||
if (ctrl.signal.aborted || isAbortError(e)) return
|
||||
// 진짜 에러
|
||||
throw e
|
||||
} finally {
|
||||
if (get().currentController === ctrl) set({ currentController: null })
|
||||
}
|
||||
},
|
||||
stop: () => {
|
||||
get().currentController?.abort()
|
||||
set({ currentController: null })
|
||||
},
|
||||
}))
|
||||
```
|
||||
|
||||
핵심 포인트:
|
||||
|
||||
- abort는 **에러가 아님** — `signal.aborted || isAbortError(e)` 감지해서 catch에서 silently return
|
||||
- finally에서 controller가 여전히 "내 거"인지 확인 후 null화 (race condition 방지)
|
||||
- stop()은 abort + state 정리 한꺼번에
|
||||
|
||||
---
|
||||
|
||||
## 4. 백엔드 SSE 어댑터 (Python · FastAPI · langchain)
|
||||
|
||||
프론트 contract와 짝이 되는 Python 헬퍼. 다음 프로젝트에 옮길 때 같이 복사.
|
||||
|
||||
### 이벤트 빌더 (`services/llm/sse.py`)
|
||||
|
||||
```python
|
||||
"""SSE 이벤트 직렬화 헬퍼.
|
||||
|
||||
sse_starlette.EventSourceResponse가 받는 dict({"event": str, "data": str})로 빌드.
|
||||
data는 항상 JSON 문자열. 한글은 ensure_ascii=False로 보존.
|
||||
"""
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _encode(payload: dict[str, Any]) -> str:
|
||||
return json.dumps(payload, ensure_ascii=False)
|
||||
|
||||
|
||||
def sse_token(delta: str) -> dict[str, str]:
|
||||
"""LLM 토큰 한 조각."""
|
||||
return {"event": "token", "data": _encode({"delta": delta})}
|
||||
|
||||
|
||||
def sse_result(payload: dict[str, Any]) -> dict[str, str]:
|
||||
"""토큰 스트림 직전에 한 번 보내는 사전 데이터 (선택)."""
|
||||
return {"event": "result", "data": _encode(payload)}
|
||||
|
||||
|
||||
def sse_done() -> dict[str, str]:
|
||||
"""정상 종료 시그널."""
|
||||
return {"event": "done", "data": "{}"}
|
||||
|
||||
|
||||
def sse_error(message: str, code: str | None = None) -> dict[str, str]:
|
||||
"""오류 종료 시그널."""
|
||||
return {"event": "error", "data": _encode({"message": message, "code": code})}
|
||||
```
|
||||
|
||||
### langchain `astream_events` → 토큰 yield
|
||||
|
||||
```python
|
||||
"""langchain Runnable의 astream_events(version="v2")에서 chat 모델 stream chunk만 추출."""
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
|
||||
async def stream_chat_tokens(chain, input: dict) -> AsyncIterator[str]:
|
||||
async for event in chain.astream_events(input, version="v2"):
|
||||
if event["event"] != "on_chat_model_stream":
|
||||
continue
|
||||
chunk = event["data"].get("chunk")
|
||||
if chunk is None:
|
||||
continue
|
||||
content = getattr(chunk, "content", "")
|
||||
if content:
|
||||
yield content
|
||||
```
|
||||
|
||||
### FastAPI 라우트 예시
|
||||
|
||||
```python
|
||||
from fastapi import APIRouter
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/chat/stream")
|
||||
async def chat_stream(req: ChatRequest):
|
||||
async def gen():
|
||||
try:
|
||||
chain = build_chain(req.system_prompt, req.messages)
|
||||
async for delta in stream_chat_tokens(chain, {}):
|
||||
yield sse_token(delta)
|
||||
yield sse_done()
|
||||
except Exception as e:
|
||||
yield sse_error(str(e), code=type(e).__name__)
|
||||
|
||||
return EventSourceResponse(gen())
|
||||
```
|
||||
|
||||
`result` 이벤트가 필요한 경우(예: SAP 조회 결과 + LLM 요약):
|
||||
|
||||
```python
|
||||
async def gen():
|
||||
items = await fetch_items(req.table_name)
|
||||
yield sse_result({"items": [it.model_dump() for it in items]})
|
||||
chain = build_summary_chain(items, req.table_name)
|
||||
async for delta in stream_chat_tokens(chain, {}):
|
||||
yield sse_token(delta)
|
||||
yield sse_done()
|
||||
```
|
||||
|
||||
### 의존성
|
||||
|
||||
```toml
|
||||
# pyproject.toml
|
||||
sse-starlette = "^2.0"
|
||||
langchain-openai = "^0.2"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 다음 프로젝트 이식 체크리스트
|
||||
|
||||
1. **프론트**: `2_frontend/src/lib/streaming/` 폴더 통째 복사
|
||||
- import alias `@/lib/streaming` 또는 상대 경로 맞춤
|
||||
- React 18+, Tailwind 3+ (`animate-pulse`, `list-disc`, `text-muted-foreground` 등)
|
||||
- `npm i @microsoft/fetch-event-source react-markdown remark-gfm` 한 줄
|
||||
- `streamSSE`가 `env.apiBaseUrl`과 `/auth/refresh` 엔드포인트를 가정 — 프로젝트에 맞게 수정 또는 그대로 사용
|
||||
2. **백엔드**: 위 §4 헬퍼 3개를 `services/llm/sse.py`로 복사
|
||||
- `sse-starlette`, `langchain-openai` 설치
|
||||
- 라우트는 프로젝트 컨벤션 따름
|
||||
3. **contract**: `event: result/token/done/error` 표준 유지 — 양쪽 다 이 모듈을 쓰면 자동으로 호환
|
||||
|
||||
## 외부 의존성
|
||||
|
||||
| 모듈 | 의존 |
|
||||
| ---------------------------------- | ------------------------------------------------------ |
|
||||
| `useSmoothedText` | React 18+ |
|
||||
| `StreamingText` | React 18+, Tailwind 3+, `react-markdown`, `remark-gfm` |
|
||||
| `streamSSE`, `streamLLM` | `@microsoft/fetch-event-source`, `@/config/env` |
|
||||
| `useStreamSession`, `isAbortError` | React 18+ (hook), 없음 (helper) |
|
||||
|
||||
`useSmoothedText`만 따로 쓰려면 React만 있으면 됨 — 마크다운 렌더가 필요 없는 곳에선 typewriter 결과 텍스트를 직접 그려도 됨.
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect, vi } from "vitest"
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import userEvent from "@testing-library/user-event"
|
||||
import { StoppedNotice } from "./StoppedNotice"
|
||||
|
||||
describe("StoppedNotice", () => {
|
||||
it("기본 메시지 + 재시도 버튼 렌더", () => {
|
||||
render(<StoppedNotice onRetry={() => {}} />)
|
||||
expect(screen.getByText(/응답이 중단되었습니다./)).toBeInTheDocument()
|
||||
expect(screen.getByRole("button", { name: /재시도/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("재시도 클릭 시 onRetry 호출", async () => {
|
||||
const onRetry = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<StoppedNotice onRetry={onRetry} />)
|
||||
await user.click(screen.getByRole("button", { name: /재시도/ }))
|
||||
expect(onRetry).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("disabled=true: 재시도 버튼 비활성", () => {
|
||||
render(<StoppedNotice onRetry={() => {}} disabled />)
|
||||
expect(screen.getByRole("button", { name: /재시도/ })).toBeDisabled()
|
||||
})
|
||||
|
||||
it("onRetry 미전달: 재시도 버튼 미렌더, 안내 텍스트만", () => {
|
||||
render(<StoppedNotice />)
|
||||
expect(screen.getByText(/응답이 중단되었습니다./)).toBeInTheDocument()
|
||||
expect(screen.queryByRole("button", { name: /재시도/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("커스텀 message prop 노출", () => {
|
||||
render(<StoppedNotice message="요청을 멈췄어요." />)
|
||||
expect(screen.getByText(/요청을 멈췄어요./)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/응답이 중단되었습니다./)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Info } from "lucide-react"
|
||||
import { Button } from "@/shared/ui/button"
|
||||
|
||||
export interface StoppedNoticeProps {
|
||||
/** 재시도 클릭 콜백. 없으면 재시도 버튼 자체가 렌더되지 않음. */
|
||||
onRetry?: () => void
|
||||
/** 다른 요청이 진행 중일 때 등 재시도를 막아야 할 때 true. */
|
||||
disabled?: boolean
|
||||
/** 안내 문구 커스터마이즈. 기본 "응답이 중단되었습니다.". */
|
||||
message?: string
|
||||
}
|
||||
|
||||
export function StoppedNotice({ onRetry, disabled, message }: StoppedNoticeProps) {
|
||||
return (
|
||||
<div className="bg-muted/40 text-muted-foreground flex w-full max-w-[90%] items-center justify-between gap-3 rounded-lg border px-3 py-2 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Info className="size-4 shrink-0" aria-hidden />
|
||||
<span>{message ?? "응답이 중단되었습니다."}</span>
|
||||
</div>
|
||||
{onRetry && (
|
||||
<Button type="button" variant="outline" size="sm" disabled={disabled} onClick={onRetry}>
|
||||
재시도
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useEffect, useMemo, useRef } from "react"
|
||||
import Markdown, { type Components } from "react-markdown"
|
||||
import remarkGfm from "remark-gfm"
|
||||
import { useSmoothedText, type SmoothedTextOptions } from "./useSmoothedText"
|
||||
|
||||
/* ---------- 마크다운 렌더러 ---------- */
|
||||
|
||||
// react-markdown 의 components prop — 각 HTML 태그에 Tailwind 클래스를 입혀
|
||||
// chat 버블 디자인과 톤을 맞춤. GFM(테이블·취소선·체크리스트)도 함께 처리.
|
||||
const MD_COMPONENTS: Components = {
|
||||
p: ({ children }) => <p className="whitespace-pre-wrap">{children}</p>,
|
||||
ul: ({ children }) => (
|
||||
<ul className="marker:text-muted-foreground list-disc space-y-1 pl-5">{children}</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="marker:text-muted-foreground list-decimal space-y-1 pl-5">{children}</ol>
|
||||
),
|
||||
li: ({ children }) => <li>{children}</li>,
|
||||
strong: ({ children }) => <strong className="font-semibold">{children}</strong>,
|
||||
em: ({ children }) => <em className="italic">{children}</em>,
|
||||
del: ({ children }) => <del className="line-through opacity-70">{children}</del>,
|
||||
a: ({ children, href }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline underline-offset-2 hover:no-underline"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
// 인라인 code 와 블록 code 모두 통과. pre 가 따로 래핑하니까 여기선 항상 인라인 스타일.
|
||||
// 블록 안 code 는 pre 의 폰트/배경을 상속받게 className 만 보존.
|
||||
code: ({ className, children }) => {
|
||||
const isBlock = /language-(\w+)/.test(className ?? "")
|
||||
if (isBlock) {
|
||||
return <code className={className}>{children}</code>
|
||||
}
|
||||
return (
|
||||
<code className="bg-foreground/10 rounded px-1 py-0.5 font-mono text-[0.9em]">
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
},
|
||||
pre: ({ children }) => (
|
||||
<pre className="bg-foreground/5 overflow-x-auto rounded-md p-3 font-mono text-xs leading-relaxed">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-muted-foreground/40 text-muted-foreground border-l-2 pl-3 italic">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
h1: ({ children }) => <h1 className="text-base font-semibold">{children}</h1>,
|
||||
h2: ({ children }) => <h2 className="text-sm font-semibold">{children}</h2>,
|
||||
h3: ({ children }) => <h3 className="text-sm font-semibold">{children}</h3>,
|
||||
hr: () => <hr className="border-foreground/10" />,
|
||||
// GFM 테이블
|
||||
table: ({ children }) => (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-xs">{children}</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }) => <thead className="border-foreground/20 border-b">{children}</thead>,
|
||||
th: ({ children }) => <th className="px-2 py-1 text-left font-semibold">{children}</th>,
|
||||
td: ({ children }) => <td className="border-foreground/10 border-t px-2 py-1">{children}</td>,
|
||||
}
|
||||
|
||||
const REMARK_PLUGINS = [remarkGfm]
|
||||
|
||||
/* ---------- 컴포넌트 ---------- */
|
||||
|
||||
export interface StreamingTextProps {
|
||||
/** 누적된 전체 텍스트 (LLM이 보낸 만큼) */
|
||||
text: string
|
||||
/** 스트림 진행 중 여부 — typewriter 페이스 + 캐럿 표시 결정 */
|
||||
isStreaming: boolean
|
||||
/** typewriter 페이스 튜닝 (선택) */
|
||||
cps?: SmoothedTextOptions
|
||||
/** true면 typewriter 건너뛰고 받은 토큰을 즉시 노출 (가장 빠른 출력). */
|
||||
instant?: boolean
|
||||
/** 컨테이너 className 오버라이드 */
|
||||
className?: string
|
||||
/** true면 typewriter를 그 자리에 동결 — 더 안 풀고 full로도 안 스냅. STOP 후 "딱 여기서 멈춤" 용도. */
|
||||
frozen?: boolean
|
||||
/** typewriter가 다 풀렸거나 frozen으로 멈췄을 때 한 번 호출. 호출처에서 isLoading 종료에 씀. */
|
||||
onRevealEnd?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* LLM 응답을 typewriter로 한 글자씩 흘리며, react-markdown 으로 렌더.
|
||||
*
|
||||
* GFM(테이블·취소선·체크리스트) 지원. Tailwind 클래스는 MD_COMPONENTS 로 주입.
|
||||
*
|
||||
* 사용 예:
|
||||
* ```tsx
|
||||
* <StreamingText text={message.summary} isStreaming={message.isStreaming} />
|
||||
* ```
|
||||
*/
|
||||
// instant 모드용 cps — 한 프레임에 전체를 풀어버릴 만큼 크게. reveal/캐럿/onRevealEnd
|
||||
// 로직은 useSmoothedText의 것을 그대로 재사용(분기 없이 단일 경로 유지)하고 속도만 사실상 즉시로 만듦.
|
||||
const INSTANT_CPS = 1e9
|
||||
|
||||
export function StreamingText({
|
||||
text,
|
||||
isStreaming,
|
||||
cps,
|
||||
instant = false,
|
||||
className = "space-y-2 text-sm leading-relaxed",
|
||||
frozen,
|
||||
onRevealEnd,
|
||||
}: StreamingTextProps) {
|
||||
const smoothOpts = useMemo(
|
||||
() => (instant ? { baseCps: INSTANT_CPS, maxCps: INSTANT_CPS, frozen } : { ...cps, frozen }),
|
||||
[instant, cps, frozen]
|
||||
)
|
||||
const { text: shown, revealing } = useSmoothedText(text, isStreaming, smoothOpts)
|
||||
|
||||
// revealing이 true → false로 떨어지는 단 한 순간에 콜백 호출.
|
||||
// 마운트 직후부터 revealing=false인 경우(과거 메시지)에도 한 번 통지 — 호출 측이 activeStreamId 같은 걸로
|
||||
// 가드해서 무관 메시지 신호를 무시하면 됨.
|
||||
const lastRevealingRef = useRef<boolean | null>(null)
|
||||
useEffect(() => {
|
||||
const prev = lastRevealingRef.current
|
||||
lastRevealingRef.current = revealing
|
||||
if (!revealing && prev !== false) {
|
||||
onRevealEnd?.()
|
||||
}
|
||||
}, [revealing, onRevealEnd])
|
||||
|
||||
if (shown.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Markdown remarkPlugins={REMARK_PLUGINS} components={MD_COMPONENTS}>
|
||||
{shown}
|
||||
</Markdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* AbortError 감지 헬퍼.
|
||||
*
|
||||
* fetch / fetchEventSource는 abort 시 다음 셋 중 하나로 던짐:
|
||||
* - `DOMException` with `name === "AbortError"`
|
||||
* - `Error` with `message` 안에 "abort" (브라우저/폴리필별 차이)
|
||||
* - 단순히 signal.aborted 만 true로 두고 끝나는 경우 (드뭄)
|
||||
*
|
||||
* 호출 측 catch에서 "이게 사용자가 누른 stop이야 vs 진짜 에러야"를 구분할 때 씀.
|
||||
*
|
||||
* 사용 예:
|
||||
* ```ts
|
||||
* try {
|
||||
* await streamLLM({ ..., signal: ctrl.signal })
|
||||
* } catch (e) {
|
||||
* if (ctrl.signal.aborted || isAbortError(e)) {
|
||||
* // stop 누른 것 — 정상 종료처럼 처리
|
||||
* } else {
|
||||
* // 진짜 에러 — 에러 UI 표시
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function isAbortError(e: unknown): boolean {
|
||||
return e instanceof Error && (e.name === "AbortError" || /abort/i.test(e.message))
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// typewriter 렌더러
|
||||
export { useSmoothedText } from "./useSmoothedText"
|
||||
export type { SmoothedTextOptions, SmoothedTextResult } from "./useSmoothedText"
|
||||
export { StreamingText } from "./StreamingText"
|
||||
export type { StreamingTextProps } from "./StreamingText"
|
||||
|
||||
// SSE 스트림 (저수준)
|
||||
export { streamSSE } from "./sse"
|
||||
export type { SSEEvent, StreamSSEOptions } from "./sse"
|
||||
|
||||
// LLM 표준 contract 어댑터 (event: result/token/done/error)
|
||||
export { streamLLM } from "./streamLLM"
|
||||
export type {
|
||||
AgentStep,
|
||||
AgentToolCall,
|
||||
AgentToolResult,
|
||||
ClarifyCandidate,
|
||||
LLMDonePayload,
|
||||
LLMStreamHandlers,
|
||||
LLMUsagePayload,
|
||||
StreamLLMOptions,
|
||||
} from "./streamLLM"
|
||||
|
||||
// AbortController 통합
|
||||
export { isAbortError } from "./abort"
|
||||
export { useStreamSession } from "./useStreamSession"
|
||||
export type { StreamSessionResult } from "./useStreamSession"
|
||||
|
||||
// stop 안내 카드
|
||||
export { StoppedNotice } from "./StoppedNotice"
|
||||
export type { StoppedNoticeProps } from "./StoppedNotice"
|
||||
@@ -0,0 +1,167 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"
|
||||
import { streamSSE } from "./sse"
|
||||
import * as fes from "@microsoft/fetch-event-source"
|
||||
import { setAccessToken } from "@/lib/auth/tokenProvider"
|
||||
|
||||
vi.mock("@microsoft/fetch-event-source", () => ({
|
||||
fetchEventSource: vi.fn(),
|
||||
}))
|
||||
|
||||
const originalFetch = global.fetch
|
||||
|
||||
describe("streamSSE", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
global.fetch = originalFetch
|
||||
})
|
||||
|
||||
afterEach(() => setAccessToken(null))
|
||||
|
||||
it("쿠키 기반(credentials: include) + path에 base URL prefix", async () => {
|
||||
const fetchEventSource = fes.fetchEventSource as ReturnType<typeof vi.fn>
|
||||
fetchEventSource.mockResolvedValue(undefined)
|
||||
|
||||
await streamSSE({
|
||||
path: "/chat/stream",
|
||||
body: { message: "hi" },
|
||||
onEvent: vi.fn(),
|
||||
})
|
||||
|
||||
expect(fetchEventSource).toHaveBeenCalledTimes(1)
|
||||
const [url, opts] = fetchEventSource.mock.calls[0]
|
||||
expect(url).toContain("/api/v1/chat/stream")
|
||||
expect(opts.method).toBe("POST")
|
||||
expect(opts.credentials).toBe("include")
|
||||
expect(opts.headers["Content-Type"]).toBe("application/json")
|
||||
expect(opts.headers).not.toHaveProperty("Authorization")
|
||||
expect(opts.body).toBe(JSON.stringify({ message: "hi" }))
|
||||
})
|
||||
|
||||
it("onmessage가 onEvent로 전달", async () => {
|
||||
const onEvent = vi.fn()
|
||||
const fetchEventSource = fes.fetchEventSource as ReturnType<typeof vi.fn>
|
||||
fetchEventSource.mockImplementation(async (_url: string, opts) => {
|
||||
opts.onmessage?.({ event: "chunk", data: "h" } as never)
|
||||
opts.onmessage?.({ event: "chunk", data: "i" } as never)
|
||||
opts.onmessage?.({ event: "done", data: "" } as never)
|
||||
})
|
||||
|
||||
await streamSSE({ path: "/chat/stream", body: { message: "x" }, onEvent })
|
||||
|
||||
expect(onEvent).toHaveBeenCalledTimes(3)
|
||||
expect(onEvent).toHaveBeenNthCalledWith(1, {
|
||||
event: "chunk",
|
||||
data: "h",
|
||||
id: undefined,
|
||||
})
|
||||
expect(onEvent).toHaveBeenNthCalledWith(3, {
|
||||
event: "done",
|
||||
data: "",
|
||||
id: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it("onopen 응답이 401이면 fetch refresh 후 재시도", async () => {
|
||||
const fetchEventSource = fes.fetchEventSource as ReturnType<typeof vi.fn>
|
||||
let callCount = 0
|
||||
fetchEventSource.mockImplementation(async (_url: string, opts) => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
await opts.onopen?.({ ok: false, status: 401 } as Response)
|
||||
}
|
||||
// 2번째는 정상
|
||||
})
|
||||
// SSE 자체 refresh는 글로벌 fetch로 /auth/refresh POST
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ success: true }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
)
|
||||
|
||||
await streamSSE({ path: "/chat/stream", body: { message: "x" }, onEvent: vi.fn() })
|
||||
|
||||
expect(fetchEventSource).toHaveBeenCalledTimes(2)
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1)
|
||||
const refreshUrl = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0]
|
||||
expect(refreshUrl).toContain("/api/v1/auth/refresh")
|
||||
})
|
||||
|
||||
it("토큰 있으면 Authorization: Bearer 헤더를 추가", async () => {
|
||||
setAccessToken("tok")
|
||||
const fetchEventSource = fes.fetchEventSource as ReturnType<typeof vi.fn>
|
||||
fetchEventSource.mockResolvedValue(undefined)
|
||||
|
||||
await streamSSE({ path: "/chat/stream", body: { message: "hi" }, onEvent: vi.fn() })
|
||||
|
||||
const [, opts] = fetchEventSource.mock.calls[0]
|
||||
expect(opts.headers.Authorization).toBe("Bearer tok")
|
||||
})
|
||||
|
||||
it("onerror 로 온 오류는 onError 콜백을 부르고 다시 throw(자동 재접속 차단)", async () => {
|
||||
const fetchEventSource = fes.fetchEventSource as ReturnType<typeof vi.fn>
|
||||
const boom = new Error("stream boom")
|
||||
fetchEventSource.mockImplementation(async (_url: string, opts) => {
|
||||
opts.onerror?.(boom) // 코드의 onerror 가 onError 호출 후 throw → 여기서 propagate
|
||||
})
|
||||
const onError = vi.fn()
|
||||
|
||||
await expect(
|
||||
streamSSE({ path: "/chat/stream", body: {}, onEvent: vi.fn(), onError })
|
||||
).rejects.toThrow("stream boom")
|
||||
expect(onError).toHaveBeenCalledWith(boom)
|
||||
})
|
||||
|
||||
it("refresh 가 실패하면 'SSE refresh failed' 로 종료하고 재시도 안 함", async () => {
|
||||
const fetchEventSource = fes.fetchEventSource as ReturnType<typeof vi.fn>
|
||||
fetchEventSource.mockImplementation(async (_url: string, opts) => {
|
||||
await opts.onopen?.({ ok: false, status: 401 } as Response)
|
||||
})
|
||||
global.fetch = vi.fn().mockResolvedValue(new Response(null, { status: 500 })) // refresh 실패
|
||||
|
||||
await expect(streamSSE({ path: "/chat/stream", body: {}, onEvent: vi.fn() })).rejects.toThrow(
|
||||
"SSE refresh failed"
|
||||
)
|
||||
expect(fetchEventSource).toHaveBeenCalledTimes(1) // 두 번째 open 없음
|
||||
})
|
||||
|
||||
it("401 이 아닌 open 실패는 'SSE open failed: {status}' 로 종료", async () => {
|
||||
const fetchEventSource = fes.fetchEventSource as ReturnType<typeof vi.fn>
|
||||
fetchEventSource.mockImplementation(async (_url: string, opts) => {
|
||||
await opts.onopen?.({ ok: false, status: 500 } as Response)
|
||||
})
|
||||
|
||||
await expect(streamSSE({ path: "/chat/stream", body: {}, onEvent: vi.fn() })).rejects.toThrow(
|
||||
"SSE open failed: 500"
|
||||
)
|
||||
expect(fetchEventSource).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("refresh 후에도 또 401 이면 무한 refresh 없이 'SSE open failed: 401'", async () => {
|
||||
const fetchEventSource = fes.fetchEventSource as ReturnType<typeof vi.fn>
|
||||
fetchEventSource.mockImplementation(async (_url: string, opts) => {
|
||||
await opts.onopen?.({ ok: false, status: 401 } as Response)
|
||||
})
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ success: true }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
)
|
||||
|
||||
await expect(streamSSE({ path: "/chat/stream", body: {}, onEvent: vi.fn() })).rejects.toThrow(
|
||||
"SSE open failed: 401"
|
||||
)
|
||||
expect(fetchEventSource).toHaveBeenCalledTimes(2) // 최초 + refresh 후 1회, 그 이상 없음
|
||||
})
|
||||
|
||||
it("signal 을 fetchEventSource 로 그대로 넘긴다(중단 배선)", async () => {
|
||||
const fetchEventSource = fes.fetchEventSource as ReturnType<typeof vi.fn>
|
||||
fetchEventSource.mockResolvedValue(undefined)
|
||||
const ctrl = new AbortController()
|
||||
|
||||
await streamSSE({ path: "/chat/stream", body: {}, onEvent: vi.fn(), signal: ctrl.signal })
|
||||
|
||||
expect(fetchEventSource.mock.calls[0][1].signal).toBe(ctrl.signal)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { fetchEventSource } from "@microsoft/fetch-event-source"
|
||||
import { env } from "@/config/env"
|
||||
import { getAccessToken } from "@/lib/auth/tokenProvider"
|
||||
|
||||
/**
|
||||
* SSE 한 이벤트의 정규화된 모양.
|
||||
* `event: <type>` 라인이 없으면 type은 "message"로 채움.
|
||||
*/
|
||||
export interface SSEEvent {
|
||||
event: string
|
||||
data: string
|
||||
id?: string
|
||||
}
|
||||
|
||||
export interface StreamSSEOptions {
|
||||
path: string
|
||||
body: unknown
|
||||
onEvent: (e: SSEEvent) => void
|
||||
onError?: (e: unknown) => void
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
class RetryableUnauthorized extends Error {}
|
||||
|
||||
/**
|
||||
* 쿠키 기반 refresh — body 없이 쿠키만 들고 백엔드 호출.
|
||||
* axios 인터셉터의 자동 refresh와 별개로 SSE 흐름 안에서만 1회 시도.
|
||||
*/
|
||||
async function refreshOnce(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${env.apiBaseUrl}/auth/refresh`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
return res.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function open(opts: StreamSSEOptions, retried: boolean): Promise<void> {
|
||||
const token = getAccessToken()
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" }
|
||||
if (token) headers.Authorization = `Bearer ${token}`
|
||||
await fetchEventSource(`${env.apiBaseUrl}${opts.path}`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers,
|
||||
body: JSON.stringify(opts.body),
|
||||
signal: opts.signal,
|
||||
openWhenHidden: true,
|
||||
onopen: async (res) => {
|
||||
if (res.ok) return
|
||||
if (res.status === 401 && !retried) {
|
||||
throw new RetryableUnauthorized()
|
||||
}
|
||||
throw new Error(`SSE open failed: ${res.status}`)
|
||||
},
|
||||
onmessage: (msg) => {
|
||||
opts.onEvent({ event: msg.event || "message", data: msg.data, id: msg.id })
|
||||
},
|
||||
onerror: (err) => {
|
||||
if (err instanceof RetryableUnauthorized) throw err
|
||||
// 자동 reconnect 방지: throw하면 종료
|
||||
opts.onError?.(err)
|
||||
throw err
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* POST + SSE 스트림. `@microsoft/fetch-event-source` 위에 401 자동 refresh 추가.
|
||||
*
|
||||
* - `signal`로 중단 가능 (AbortController 연동)
|
||||
* - 401 → /auth/refresh POST 한 번 → 재시도
|
||||
* - `onEvent`는 매 이벤트마다 호출 (event 타입별 분기는 호출 측에서; 표준 LLM 이벤트라면 `streamLLM` 사용)
|
||||
*/
|
||||
export async function streamSSE(opts: StreamSSEOptions): Promise<void> {
|
||||
try {
|
||||
await open(opts, false)
|
||||
} catch (err) {
|
||||
if (err instanceof RetryableUnauthorized) {
|
||||
const ok = await refreshOnce()
|
||||
if (!ok) throw new Error("SSE refresh failed")
|
||||
await open(opts, true)
|
||||
return
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import { streamLLM, type LLMStreamHandlers } from "./streamLLM"
|
||||
import * as sse from "./sse"
|
||||
|
||||
vi.mock("./sse", () => ({ streamSSE: vi.fn() }))
|
||||
|
||||
type Ev = { event: string; data: string }
|
||||
|
||||
/** streamSSE mock 이 주어진 이벤트들을 순서대로 흘리고, streamError 있으면 마지막에 onError 호출. */
|
||||
function mockStream(events: Ev[], streamError?: unknown) {
|
||||
const streamSSE = sse.streamSSE as ReturnType<typeof vi.fn>
|
||||
streamSSE.mockImplementation(
|
||||
async (opts: { onEvent: (e: Ev) => void; onError?: (e: unknown) => void }) => {
|
||||
for (const e of events) opts.onEvent(e)
|
||||
if (streamError !== undefined) opts.onError?.(streamError)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/** onToken/onDone 은 필수라 기본 stub 채우고, 넘긴 것만 덮어씀. */
|
||||
function run(handlers: Partial<LLMStreamHandlers>) {
|
||||
return streamLLM({
|
||||
path: "/chat/stream",
|
||||
body: {},
|
||||
handlers: { onToken: vi.fn(), onDone: vi.fn(), ...handlers },
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
describe("streamLLM title 이벤트", () => {
|
||||
it("title 이벤트를 onTitle 로 전달한다", async () => {
|
||||
mockStream([{ event: "title", data: JSON.stringify({ title: "판매문서 조인" }) }])
|
||||
const onTitle = vi.fn()
|
||||
await run({ onTitle })
|
||||
expect(onTitle).toHaveBeenCalledWith("판매문서 조인")
|
||||
})
|
||||
|
||||
it("malformed title 은 조용히 무시(throw 안 함)", async () => {
|
||||
mockStream([{ event: "title", data: "not-json" }])
|
||||
const onTitle = vi.fn()
|
||||
await expect(run({ onTitle })).resolves.toBeUndefined()
|
||||
expect(onTitle).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("streamLLM token 이벤트", () => {
|
||||
it("delta 를 onToken 으로 순서대로 누적 전달", async () => {
|
||||
mockStream([
|
||||
{ event: "token", data: JSON.stringify({ delta: "안" }) },
|
||||
{ event: "token", data: JSON.stringify({ delta: "녕" }) },
|
||||
])
|
||||
const onToken = vi.fn()
|
||||
await run({ onToken })
|
||||
expect(onToken.mock.calls).toEqual([["안"], ["녕"]])
|
||||
})
|
||||
|
||||
it("malformed token 하나는 스킵하고 다음 token 에서 회복", async () => {
|
||||
mockStream([
|
||||
{ event: "token", data: "깨진데이터" },
|
||||
{ event: "token", data: JSON.stringify({ delta: "ok" }) },
|
||||
])
|
||||
const onToken = vi.fn()
|
||||
await run({ onToken })
|
||||
expect(onToken.mock.calls).toEqual([["ok"]])
|
||||
})
|
||||
|
||||
it("delta 가 문자열이 아니면 무시(빈 토큰 방어)", async () => {
|
||||
mockStream([{ event: "token", data: JSON.stringify({ delta: 123 }) }])
|
||||
const onToken = vi.fn()
|
||||
await run({ onToken })
|
||||
expect(onToken).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("streamLLM done 이벤트", () => {
|
||||
it("traceId 를 파싱해 onDone 으로 전달", async () => {
|
||||
mockStream([{ event: "done", data: JSON.stringify({ traceId: "trace-1" }) }])
|
||||
const onDone = vi.fn()
|
||||
await run({ onDone })
|
||||
expect(onDone).toHaveBeenCalledWith({ traceId: "trace-1" })
|
||||
})
|
||||
|
||||
it("빈 data 면 onDone({})", async () => {
|
||||
mockStream([{ event: "done", data: "" }])
|
||||
const onDone = vi.fn()
|
||||
await run({ onDone })
|
||||
expect(onDone).toHaveBeenCalledWith({})
|
||||
})
|
||||
|
||||
it("malformed done 이어도 onDone({}) 로 정상 종료 보장", async () => {
|
||||
mockStream([{ event: "done", data: "{깨짐" }])
|
||||
const onDone = vi.fn()
|
||||
await run({ onDone })
|
||||
expect(onDone).toHaveBeenCalledWith({})
|
||||
})
|
||||
})
|
||||
|
||||
describe("streamLLM error 이벤트", () => {
|
||||
it("message 를 담은 Error 로 onError 호출", async () => {
|
||||
mockStream([{ event: "error", data: JSON.stringify({ message: "모델 과부하", code: "429" }) }])
|
||||
const onError = vi.fn()
|
||||
await run({ onError })
|
||||
expect(onError).toHaveBeenCalledTimes(1)
|
||||
expect(onError.mock.calls[0][0]).toBeInstanceOf(Error)
|
||||
expect(onError.mock.calls[0][0].message).toBe("모델 과부하")
|
||||
})
|
||||
|
||||
it("malformed error 는 generic 메시지로 fallback", async () => {
|
||||
mockStream([{ event: "error", data: "not-json" }])
|
||||
const onError = vi.fn()
|
||||
await run({ onError })
|
||||
expect(onError.mock.calls[0][0].message).toBe("LLM SSE error")
|
||||
})
|
||||
})
|
||||
|
||||
describe("streamLLM result 이벤트", () => {
|
||||
it("파싱한 JSON 을 onResult 로 전달", async () => {
|
||||
mockStream([{ event: "result", data: JSON.stringify({ items: [1, 2] }) }])
|
||||
const onResult = vi.fn()
|
||||
await run({ onResult })
|
||||
expect(onResult).toHaveBeenCalledWith({ items: [1, 2] })
|
||||
})
|
||||
|
||||
it("malformed result 는 무시하고 토큰 흐름 유지", async () => {
|
||||
mockStream([
|
||||
{ event: "result", data: "깨짐" },
|
||||
{ event: "token", data: JSON.stringify({ delta: "x" }) },
|
||||
])
|
||||
const onResult = vi.fn()
|
||||
const onToken = vi.fn()
|
||||
await expect(run({ onResult, onToken })).resolves.toBeUndefined()
|
||||
expect(onResult).not.toHaveBeenCalled()
|
||||
expect(onToken).toHaveBeenCalledWith("x")
|
||||
})
|
||||
})
|
||||
|
||||
describe("streamLLM clarify 이벤트", () => {
|
||||
it("candidates 배열을 onClarify 로 전달", async () => {
|
||||
const candidates = [{ skill: "join", reason: "판매문서" }]
|
||||
mockStream([{ event: "clarify", data: JSON.stringify({ candidates }) }])
|
||||
const onClarify = vi.fn()
|
||||
await run({ onClarify })
|
||||
expect(onClarify).toHaveBeenCalledWith(candidates)
|
||||
})
|
||||
|
||||
it("candidates 가 배열이 아니면 onClarify 호출 안 함", async () => {
|
||||
mockStream([{ event: "clarify", data: JSON.stringify({ candidates: "nope" }) }])
|
||||
const onClarify = vi.fn()
|
||||
await run({ onClarify })
|
||||
expect(onClarify).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("streamLLM usage 이벤트", () => {
|
||||
it("사용량 payload 를 onUsage 로 전달", async () => {
|
||||
const usage = { used: 1200, limit: 200000, elapsed_ms: 3400 }
|
||||
mockStream([{ event: "usage", data: JSON.stringify(usage) }])
|
||||
const onUsage = vi.fn()
|
||||
await run({ onUsage })
|
||||
expect(onUsage).toHaveBeenCalledWith(usage)
|
||||
})
|
||||
})
|
||||
|
||||
describe("streamLLM 기타", () => {
|
||||
it("알 수 없는 event 타입은 조용히 무시", async () => {
|
||||
mockStream([{ event: "heartbeat", data: "{}" }])
|
||||
const onToken = vi.fn()
|
||||
const onDone = vi.fn()
|
||||
const onError = vi.fn()
|
||||
await expect(run({ onToken, onDone, onError })).resolves.toBeUndefined()
|
||||
expect(onToken).not.toHaveBeenCalled()
|
||||
expect(onDone).not.toHaveBeenCalled()
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("streamSSE 자체 오류(문자열)를 Error 로 래핑해 onError 전달", async () => {
|
||||
mockStream([], "network dropped")
|
||||
const onError = vi.fn()
|
||||
await run({ onError })
|
||||
expect(onError.mock.calls[0][0]).toBeInstanceOf(Error)
|
||||
expect(onError.mock.calls[0][0].message).toBe("network dropped")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,207 @@
|
||||
import { streamSSE } from "./sse"
|
||||
|
||||
/**
|
||||
* LLM SSE 표준 이벤트 contract.
|
||||
*
|
||||
* 백엔드가 보내는 이벤트 타입:
|
||||
*
|
||||
* - `result` (선택, 최대 1회): 토큰 스트림 직전에 한 번 보내는 사전 데이터
|
||||
* `data: {"items":[...]}` 또는 임의의 JSON 객체
|
||||
* - `clarify` (선택, 1회): 라우팅이 애매해 후보 skill을 내려줌. 이게 오면 token 없이 done으로 끝남.
|
||||
* `data: {"candidates":[{"skill":"...","reason":"..."}]}`
|
||||
* - `token` (N회): 토큰 한 조각
|
||||
* `data: {"delta":"..."}`
|
||||
* - `done` (1회): 정상 종료 시그널
|
||||
* `data: "{}"` 또는 `{"traceId":"..."}` (langfuse 설정 시)
|
||||
* - `error` (1회): 오류 종료 시그널
|
||||
* `data: {"message":"...","code":"..."}`
|
||||
*
|
||||
* Python 백엔드 헬퍼는 README의 "백엔드 SSE 어댑터" 섹션 참고.
|
||||
*/
|
||||
export interface LLMDonePayload {
|
||||
/** langfuse trace id — feedback 전송 시 사용. 백엔드 미설정 시 undefined. */
|
||||
traceId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* `usage` 이벤트 — done 직전 1회. 세션 컨텍스트 점유량 + 소요시간.
|
||||
* `used` = 직전 호출 total_tokens(= 이 답변의 전체 토큰), `limit` = 컨텍스트 하드 한도.
|
||||
*/
|
||||
export interface LLMUsagePayload {
|
||||
used?: number
|
||||
limit?: number
|
||||
ratio?: number
|
||||
elapsed_ms?: number
|
||||
}
|
||||
|
||||
/** `clarify` 이벤트 후보 — 백엔드 router.Candidate 와 1:1. */
|
||||
export interface ClarifyCandidate {
|
||||
skill: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
/** `step` 이벤트 — 에이전트 그래프 진행 단계 (agent.iter 데모용). */
|
||||
export interface AgentStep {
|
||||
phase: string
|
||||
detail: string
|
||||
}
|
||||
|
||||
/** `tool_call` 이벤트 — 모델이 도구 호출 시작. */
|
||||
export interface AgentToolCall {
|
||||
tool: string
|
||||
args: string
|
||||
}
|
||||
|
||||
/** `tool_result` 이벤트 — 도구 실행 결과 도착. */
|
||||
export interface AgentToolResult {
|
||||
toolCallId: string
|
||||
summary: string
|
||||
}
|
||||
|
||||
export interface LLMStreamHandlers<TResult = unknown> {
|
||||
/** `result` 이벤트 — 토큰 스트림 시작 전 1회 (선택) */
|
||||
onResult?: (data: TResult) => void
|
||||
/** `clarify` 이벤트 — 라우팅 애매. 후보 받으면 token 없이 done으로 끝남 (선택) */
|
||||
onClarify?: (candidates: ClarifyCandidate[]) => void
|
||||
/** `step` 이벤트 — 에이전트 진행 단계 (agent.iter 데모용, 선택) */
|
||||
onStep?: (step: AgentStep) => void
|
||||
/** `tool_call` 이벤트 — 모델이 도구 호출 시작 (선택) */
|
||||
onToolCall?: (call: AgentToolCall) => void
|
||||
/** `tool_result` 이벤트 — 도구 실행 결과 도착 (선택) */
|
||||
onToolResult?: (result: AgentToolResult) => void
|
||||
/** `title` 이벤트 — 첫 메시지 후 LLM 이 지은 세션 제목 (선택) */
|
||||
onTitle?: (title: string) => void
|
||||
/** `usage` 이벤트 — done 직전 세션 토큰 사용량 + 소요시간 (선택) */
|
||||
onUsage?: (usage: LLMUsagePayload) => void
|
||||
/** `token` 이벤트 — 토큰 조각 누적해서 표시 */
|
||||
onToken: (delta: string) => void
|
||||
/** `done` 이벤트 — 정상 종료. payload 에 traceId 있을 수 있음. */
|
||||
onDone: (payload: LLMDonePayload) => void
|
||||
/** `error` 이벤트 또는 SSE 자체 오류 */
|
||||
onError?: (e: Error) => void
|
||||
}
|
||||
|
||||
export interface StreamLLMOptions<TResult = unknown> {
|
||||
path: string
|
||||
body: unknown
|
||||
signal?: AbortSignal
|
||||
handlers: LLMStreamHandlers<TResult>
|
||||
}
|
||||
|
||||
interface TokenPayload {
|
||||
delta: string
|
||||
}
|
||||
|
||||
interface ClarifyPayload {
|
||||
candidates: ClarifyCandidate[]
|
||||
}
|
||||
|
||||
interface ErrorPayload {
|
||||
message: string
|
||||
code?: string | null
|
||||
}
|
||||
|
||||
interface TitlePayload {
|
||||
title: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 표준 LLM SSE contract(`event: result/token/done/error`)를 타입 있는 핸들러로 매핑.
|
||||
*
|
||||
* 사용 예:
|
||||
* ```ts
|
||||
* await streamLLM<{ items: Foo[] }>({
|
||||
* path: "/chat/stream",
|
||||
* body: { messages },
|
||||
* signal: controller.signal,
|
||||
* handlers: {
|
||||
* onResult: ({ items }) => store.setItems(items),
|
||||
* onToken: (delta) => store.appendToken(delta),
|
||||
* onDone: ({ traceId }) => store.finish(traceId),
|
||||
* onError: (e) => store.fail(e.message),
|
||||
* },
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* - `result`/`error` data는 JSON 파싱. malformed 시 안전하게 무시 또는 generic 메시지로 fallback
|
||||
* - `signal`은 그대로 `streamSSE`로 패스스루 → AbortController로 중단 가능
|
||||
* - 알 수 없는 event 타입은 무시 (확장 친화)
|
||||
*/
|
||||
export async function streamLLM<TResult = unknown>(opts: StreamLLMOptions<TResult>): Promise<void> {
|
||||
const { handlers } = opts
|
||||
return streamSSE({
|
||||
path: opts.path,
|
||||
body: opts.body,
|
||||
signal: opts.signal,
|
||||
onEvent: (e) => {
|
||||
if (e.event === "result") {
|
||||
try {
|
||||
handlers.onResult?.(JSON.parse(e.data) as TResult)
|
||||
} catch {
|
||||
// result 파싱 실패는 치명적 아님 — 토큰 흐름 유지
|
||||
}
|
||||
} else if (e.event === "title") {
|
||||
try {
|
||||
const payload = JSON.parse(e.data) as TitlePayload
|
||||
if (typeof payload.title === "string") handlers.onTitle?.(payload.title)
|
||||
} catch {
|
||||
// 제목 갱신은 부가정보 — 실패해도 토큰 흐름엔 영향 없음
|
||||
}
|
||||
} else if (e.event === "clarify") {
|
||||
try {
|
||||
const payload = JSON.parse(e.data) as ClarifyPayload
|
||||
if (Array.isArray(payload.candidates)) handlers.onClarify?.(payload.candidates)
|
||||
} catch {
|
||||
// clarify 파싱 실패 — 후보 못 보여줌. done은 별도로 옴.
|
||||
}
|
||||
} else if (e.event === "step") {
|
||||
try {
|
||||
handlers.onStep?.(JSON.parse(e.data) as AgentStep)
|
||||
} catch {
|
||||
// 진행 표시는 부가정보 — 파싱 실패해도 토큰 흐름엔 영향 없음
|
||||
}
|
||||
} else if (e.event === "tool_call") {
|
||||
try {
|
||||
handlers.onToolCall?.(JSON.parse(e.data) as AgentToolCall)
|
||||
} catch {
|
||||
// 무시 — 다음 이벤트에서 회복
|
||||
}
|
||||
} else if (e.event === "tool_result") {
|
||||
try {
|
||||
handlers.onToolResult?.(JSON.parse(e.data) as AgentToolResult)
|
||||
} catch {
|
||||
// 무시
|
||||
}
|
||||
} else if (e.event === "token") {
|
||||
try {
|
||||
const payload = JSON.parse(e.data) as TokenPayload
|
||||
if (typeof payload.delta === "string") handlers.onToken(payload.delta)
|
||||
} catch {
|
||||
// 단일 토큰 파싱 실패는 무시 (다음 token에서 회복 가능)
|
||||
}
|
||||
} else if (e.event === "usage") {
|
||||
try {
|
||||
handlers.onUsage?.(JSON.parse(e.data) as LLMUsagePayload)
|
||||
} catch {
|
||||
// 사용량은 부가정보 — 파싱 실패해도 토큰 흐름엔 영향 없음
|
||||
}
|
||||
} else if (e.event === "done") {
|
||||
try {
|
||||
const payload = e.data ? (JSON.parse(e.data) as LLMDonePayload) : {}
|
||||
handlers.onDone(payload)
|
||||
} catch {
|
||||
handlers.onDone({})
|
||||
}
|
||||
} else if (e.event === "error") {
|
||||
try {
|
||||
const payload = JSON.parse(e.data) as ErrorPayload
|
||||
handlers.onError?.(new Error(payload.message))
|
||||
} catch {
|
||||
handlers.onError?.(new Error("LLM SSE error"))
|
||||
}
|
||||
}
|
||||
// 알 수 없는 event 타입은 무시
|
||||
},
|
||||
onError: (e) => handlers.onError?.(e instanceof Error ? e : new Error(String(e))),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
|
||||
/**
|
||||
* 받은 텍스트(`full`)를 typewriter 효과로 한 글자씩 흘려보내는 hook.
|
||||
*
|
||||
* 핵심 동작:
|
||||
* - 소수점 accumulator: 매 프레임 `cps * dt / 1000`을 누적하다가 1.0 넘으면 1글자 reveal
|
||||
* → BASE_CPS=6이면 정확히 ~167ms마다 1글자 (Math.max(1,...) 같은 강제 floor 안 씀)
|
||||
* - 백로그(토큰 뭉텅이 도착)가 쌓이면 cps를 올려 한 글자씩 가속, MAX_CPS 상한
|
||||
* - displayed가 full을 따라잡을 때까지 RAF 계속 돔 (active=false여도 typing 끝까지)
|
||||
* → 스트리밍 끝나도 받은 만큼은 typewriter로 마저 찍힘 (훅 다 뿌리는 일 없음)
|
||||
* - 정적 메시지(처음부터 active=false): useState 초기값으로 즉시 full 표시
|
||||
*
|
||||
* 사용 예:
|
||||
* ```tsx
|
||||
* const { text, revealing } = useSmoothedText(message.summary, message.isStreaming)
|
||||
* return <p>{text}{revealing && <Caret />}</p>
|
||||
* ```
|
||||
*/
|
||||
export interface SmoothedTextOptions {
|
||||
/** 평상시 typewriter 페이스 (chars per second). 기본 6 = ~167ms당 1글자 */
|
||||
baseCps?: number
|
||||
/** 백로그 클 때 가속 상한. 기본 50 */
|
||||
maxCps?: number
|
||||
/**
|
||||
* true면 typewriter 진행을 그 자리에서 멈춤. displayed 더 안 늘림, full로도 안 스냅.
|
||||
* STOP 같은 "딱 여기서 멈춰" 신호에 씀. 호출 측은 보통 SSE도 같이 abort 함.
|
||||
*/
|
||||
frozen?: boolean
|
||||
/**
|
||||
* true면 마운트 시 full 이 이미 차 있어도 0에서부터 reveal. 기본은 full 에서 시작
|
||||
* (재진입 시 이미 본 내용 재생 방지). "완성본이 방금 도착했고 처음 보여주는" 경우에 씀.
|
||||
*/
|
||||
startEmpty?: boolean
|
||||
}
|
||||
|
||||
export interface SmoothedTextResult {
|
||||
/** 현재까지 노출된 텍스트 */
|
||||
text: string
|
||||
/** 캐럿(▍) 표시 조건 — 아직 다 안 찍혔거나 스트리밍 중 */
|
||||
revealing: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_BASE_CPS = 6
|
||||
const DEFAULT_MAX_CPS = 50
|
||||
const BACKLOG_THRESHOLD = 20 // 글자 — 이보다 멀면 가속
|
||||
const ACCEL_GAIN = 1.2 // 백로그 1글자당 cps 증가량
|
||||
const TAIL_BASE_MIN = 32 // active=false일 때 base cps 하한 — 마지막 문장 빠르게 따라잡기
|
||||
|
||||
export function useSmoothedText(
|
||||
full: string,
|
||||
active: boolean,
|
||||
opts: SmoothedTextOptions = {}
|
||||
): SmoothedTextResult {
|
||||
const baseCps = opts.baseCps ?? DEFAULT_BASE_CPS
|
||||
const maxCps = opts.maxCps ?? DEFAULT_MAX_CPS
|
||||
const frozen = opts.frozen ?? false
|
||||
|
||||
// 마운트 시 지금까지 쌓인 full 을 그대로 노출 시작점으로. 스트림 중 remount(목록 갔다 복귀)
|
||||
// 되면 이미 본 내용은 즉시 보이고, 이후 자라는 부분만 typewriter 로 이어감 — 처음부터 재reveal 방지.
|
||||
// fresh 시작은 full="" 라 기존과 동일(빈칸에서 reveal).
|
||||
// startEmpty: 완성본이 통째로 도착해 "처음 보여주는" 경우 — 0에서부터 reveal.
|
||||
const [displayed, setDisplayed] = useState<string>(opts.startEmpty ? "" : full)
|
||||
const fullRef = useRef(full)
|
||||
fullRef.current = full
|
||||
const accumRef = useRef(0)
|
||||
|
||||
// frozen일 땐 reset도 안 함 — 그 자리에 그대로 둠.
|
||||
useEffect(() => {
|
||||
if (frozen) return
|
||||
if (!full.startsWith(displayed)) {
|
||||
setDisplayed("")
|
||||
accumRef.current = 0
|
||||
}
|
||||
}, [full, displayed, frozen])
|
||||
|
||||
// RAF 루프 — 따라잡으면 자동 종료, 새 토큰 오면 재시작
|
||||
// active=false(스트림 종료)일 때는 effectiveBase를 올려 마지막 문장을 빠르게 따라잡음
|
||||
const caughtUp = displayed.length >= full.length
|
||||
const effectiveBase = active ? baseCps : Math.max(baseCps, TAIL_BASE_MIN)
|
||||
useEffect(() => {
|
||||
if (frozen || caughtUp) return
|
||||
let frame = 0
|
||||
let last = performance.now()
|
||||
|
||||
const tick = (now: number) => {
|
||||
const dt = now - last
|
||||
last = now
|
||||
|
||||
setDisplayed((prev) => {
|
||||
const target = fullRef.current
|
||||
if (!target.startsWith(prev)) {
|
||||
accumRef.current = 0
|
||||
return ""
|
||||
}
|
||||
if (prev.length >= target.length) {
|
||||
accumRef.current = 0
|
||||
return prev
|
||||
}
|
||||
const remaining = target.length - prev.length
|
||||
const cps =
|
||||
remaining < BACKLOG_THRESHOLD
|
||||
? effectiveBase
|
||||
: Math.min(maxCps, effectiveBase + (remaining - BACKLOG_THRESHOLD) * ACCEL_GAIN)
|
||||
accumRef.current += (cps * dt) / 1000
|
||||
const charsToReveal = Math.floor(accumRef.current)
|
||||
if (charsToReveal === 0) return prev // 같은 reference 반환 → React re-render skip
|
||||
accumRef.current -= charsToReveal
|
||||
const next = Math.min(target.length, prev.length + charsToReveal)
|
||||
return target.slice(0, next)
|
||||
})
|
||||
|
||||
frame = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
frame = requestAnimationFrame(tick)
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [frozen, caughtUp, effectiveBase, maxCps])
|
||||
|
||||
return {
|
||||
text: displayed,
|
||||
revealing: !frozen && (displayed.length < full.length || active),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { isAbortError } from "./abort"
|
||||
|
||||
/**
|
||||
* 컴포넌트 로컬 스트림 세션 hook.
|
||||
*
|
||||
* AbortController 생성·관리·중단·정리까지 한 묶음으로. 컴포넌트가 언마운트되면 진행 중인 스트림도 자동 abort.
|
||||
*
|
||||
* zustand 같은 글로벌 store 패턴을 쓰는 경우 직접 controller를 들고 다니는 게 더 자연스러움 — 이 훅은
|
||||
* 컴포넌트 단독으로 스트림 시작/중단 할 때 쓰기 편함. (자세한 store 패턴은 README 참고)
|
||||
*
|
||||
* 사용 예:
|
||||
* ```tsx
|
||||
* const { run, stop, isRunning } = useStreamSession()
|
||||
*
|
||||
* const onSubmit = (q: string) => {
|
||||
* void run(async (signal) => {
|
||||
* await streamLLM({
|
||||
* path: "/chat/stream",
|
||||
* body: { messages: [{ role: "user", content: q }] },
|
||||
* signal,
|
||||
* handlers: { onToken: (d) => setText((t) => t + d), onDone: () => {} },
|
||||
* })
|
||||
* })
|
||||
* }
|
||||
*
|
||||
* return isRunning
|
||||
* ? <button onClick={stop}>중단</button>
|
||||
* : <button onClick={() => onSubmit(input)}>보내기</button>
|
||||
* ```
|
||||
*/
|
||||
export interface StreamSessionResult<T> {
|
||||
ok: boolean
|
||||
/** 사용자가 stop()을 눌러 중단된 경우 true. ok=false일 때만 의미 있음. */
|
||||
aborted: boolean
|
||||
/** ok=true면 fn의 반환값, ok=false면 throw된 에러 */
|
||||
value?: T
|
||||
error?: unknown
|
||||
}
|
||||
|
||||
export function useStreamSession() {
|
||||
const ctrlRef = useRef<AbortController | null>(null)
|
||||
const [isRunning, setIsRunning] = useState(false)
|
||||
|
||||
// 언마운트 시 진행 중인 스트림 abort (메모리/네트워크 누수 방지)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
ctrlRef.current?.abort()
|
||||
ctrlRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const run = useCallback(
|
||||
async <T>(fn: (signal: AbortSignal) => Promise<T>): Promise<StreamSessionResult<T>> => {
|
||||
// 이전 진행 중인 스트림이 있으면 자동 취소 (사용자가 새 쿼리 보낸 경우)
|
||||
ctrlRef.current?.abort()
|
||||
const ctrl = new AbortController()
|
||||
ctrlRef.current = ctrl
|
||||
setIsRunning(true)
|
||||
try {
|
||||
const value = await fn(ctrl.signal)
|
||||
return { ok: true, aborted: false, value }
|
||||
} catch (error) {
|
||||
const aborted = ctrl.signal.aborted || isAbortError(error)
|
||||
return { ok: false, aborted, error }
|
||||
} finally {
|
||||
if (ctrlRef.current === ctrl) {
|
||||
ctrlRef.current = null
|
||||
setIsRunning(false)
|
||||
}
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const stop = useCallback(() => {
|
||||
ctrlRef.current?.abort()
|
||||
}, [])
|
||||
|
||||
return { run, stop, isRunning }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// crypto.randomUUID() 는 secure context (HTTPS / localhost) 전용.
|
||||
// HTTP 환경에서도 깨지지 않도록 fallback 둠.
|
||||
//
|
||||
// 1) crypto.randomUUID() 가능하면 그대로 — RFC4122 v4 보장
|
||||
// 2) 안 되면 crypto.getRandomValues() 로 v4 형식 직접 조립 (HTTP에서도 동작)
|
||||
// 3) 최후 fallback — Math.random + Date.now (충돌 가능성 있지만 메시지 id 용도면 충분)
|
||||
export function randomId(): string {
|
||||
const c = globalThis.crypto
|
||||
|
||||
if (c?.randomUUID) {
|
||||
return c.randomUUID()
|
||||
}
|
||||
|
||||
if (c?.getRandomValues) {
|
||||
const bytes = new Uint8Array(16)
|
||||
c.getRandomValues(bytes)
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40 // version 4
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80 // variant 10
|
||||
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("")
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
||||
}
|
||||
|
||||
return `${Date.now().toString(16)}-${Math.random().toString(16).slice(2, 10)}`
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { formatRelativeKo } from "./relativeTime"
|
||||
|
||||
describe("formatRelativeKo", () => {
|
||||
it("몇 분 전 한글 접미사를 낸다", () => {
|
||||
const fiveMinAgo = new Date(Date.now() - 5 * 60_000).toISOString()
|
||||
const out = formatRelativeKo(fiveMinAgo)
|
||||
expect(out).toContain("분")
|
||||
expect(out).toContain("전")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
import { formatDistanceToNow } from "date-fns"
|
||||
import { ko } from "date-fns/locale"
|
||||
|
||||
/** ISO 시각을 "5분 전" 같은 한글 상대시간으로. */
|
||||
export function formatRelativeKo(iso: string): string {
|
||||
return formatDistanceToNow(new Date(iso), { addSuffix: true, locale: ko })
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
Reference in New Issue
Block a user