Files
juwan-frontend/lib/api/http.ts
T
zetaloop a3f0b49112 feat(chat): implement WebSocket chat client with useChatSocket hook
Replace the stubbed chat API with a real WebSocket hook that
connects to /ws/chat and supports DM creation, messaging,
session join/leave, and history retrieval. Keep REST stub
functions for backward compatibility with existing pages
that require listChatSessions/getChatSessionById imports.
2026-05-01 17:33:29 +08:00

159 lines
4.3 KiB
TypeScript

import { isApiError, type ApiError } from "@/lib/errors"
type JsonRequestInit = Omit<RequestInit, "body" | "headers"> & {
headers?: HeadersInit
body?: BodyInit | null
json?: unknown
}
async function getServerHeaders() {
if (typeof window !== "undefined") return null
try {
const mod = await import("next/headers")
return await mod.headers()
} catch {
return null
}
}
async function resolveServerUrl(path: string) {
if (typeof window !== "undefined" || !path.startsWith("/")) return path
const requestHeaders = await getServerHeaders()
const host = requestHeaders?.get("x-forwarded-host") ?? requestHeaders?.get("host")
if (host) {
const proto =
requestHeaders?.get("x-forwarded-proto") ??
(host.startsWith("localhost") || host.startsWith("127.0.0.1") ? "http" : "https")
return `${proto}://${host}${path}`
}
const explicitBase =
process.env.NEXT_PUBLIC_BACKEND_URL?.replace(/\/$/, "") ||
process.env.BACKEND_URL?.replace(/\/$/, "") ||
process.env.INTERNAL_API_ORIGIN?.replace(/\/$/, "")
if (explicitBase) return `${explicitBase}${path}`
throw new Error(`Cannot resolve server-side API URL for ${path}`)
}
async function readJsonBody(res: Response): Promise<{ json: unknown | null; text: string }> {
const text = await res.text()
if (!text) return { json: null, text: "" }
try {
return { json: JSON.parse(text) as unknown, text }
} catch {
return { json: null, text }
}
}
function getCookieValue(name: string): string | null {
if (typeof document === "undefined") return null
if (!document.cookie) return null
for (const part of document.cookie.split("; ")) {
if (part.startsWith(`${name}=`)) return part.slice(name.length + 1)
}
return null
}
let csrfReady: Promise<void> | null = null
function needsCsrf(method: string) {
return method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE"
}
async function prepareCsrf(path: string) {
if (typeof document === "undefined") return
if (getCookieValue("__Host-XSRF-TOKEN") && getCookieValue("__Host-XSRF-GUARD")) return
csrfReady ??= fetch("/healthz", {
cache: "no-store",
credentials: "include",
})
.then(() => {})
.finally(() => {
csrfReady = null
})
await csrfReady
if (!getCookieValue("__Host-XSRF-TOKEN") && path !== "/healthz") {
await fetch("/api/v1/games?offset=0&limit=1", {
cache: "no-store",
credentials: "include",
})
}
}
function isApiErrorWithMessage(value: unknown): value is { code: number; message: string } {
if (typeof value !== "object" || value === null) return false
const v = value as { code?: unknown; message?: unknown }
return typeof v.code === "number" && typeof v.message === "string"
}
export async function httpJson<T>(path: string, init?: JsonRequestInit): Promise<T> {
if (/^https?:\/\//.test(path)) {
throw new Error("Absolute URLs are not allowed")
}
const url = await resolveServerUrl(path)
const { json, headers: headersInit, body: bodyInit, ...rest } = init ?? {}
const headers = new Headers(headersInit)
headers.set("Accept", "application/json")
const requestHeaders = await getServerHeaders()
const cookie = requestHeaders?.get("cookie")
if (cookie && !headers.has("cookie")) {
headers.set("cookie", cookie)
}
const body = json === undefined ? bodyInit : JSON.stringify(json)
if (json !== undefined && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json")
}
const method = (rest.method ?? "GET").toUpperCase()
if (needsCsrf(method) && !headers.has("XSRF-TOKEN")) {
await prepareCsrf(path)
const xsrfToken = getCookieValue("__Host-XSRF-TOKEN")
if (xsrfToken) headers.set("XSRF-TOKEN", xsrfToken)
}
const res = await fetch(url, {
...rest,
headers,
body,
credentials: rest.credentials ?? "include",
})
const { json: data, text } = await readJsonBody(res)
if (res.ok) {
return data as T
}
const apiError: ApiError | null = isApiError(data)
? data
: isApiErrorWithMessage(data)
? { code: data.code, msg: data.message }
: null
if (res.status === 401 || apiError?.code === 401) {
throw new Error("UNAUTHORIZED")
}
if (apiError) {
throw apiError
}
throw {
code: res.status,
msg: text || res.statusText || "Request failed",
} satisfies ApiError
}