Initial Commit

This commit is contained in:
2026-09-16 17:22:14 +09:00
commit 858ee9e9da
335 changed files with 123898 additions and 0 deletions
+247
View File
@@ -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()
})
})
+149
View File
@@ -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,
}
}
+24
View File
@@ -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)
})
})
+17
View File
@@ -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
}
}
+87
View File
@@ -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")
})
})
+58
View File
@@ -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()
})
})
+12
View File
@@ -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"
+129
View File
@@ -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" })
})
})
+174
View File
@@ -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" })
+12
View File
@@ -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
}
+19
View File
@@ -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,
},
},
})
+447
View File
@@ -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>
)
}
+26
View File
@@ -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))
}
+31
View File
@@ -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"
+167
View File
@@ -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)
})
})
+91
View File
@@ -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")
})
})
+207
View File
@@ -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 }
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+24
View File
@@ -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("전")
})
})
+7
View File
@@ -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 })
}
+3
View File
@@ -0,0 +1,3 @@
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}