快速開始
TypeScript 用的基於 RxJS 的類型安全 HTTP 客戶端 + (可選)Next.js/RSocket 適配器。
核心特點
- 通過從 Swagger/OpenAPI / AsyncAPI 派生的模式/自定義合約等獲得的 OpenAPI 風格的 Paths 類型(慣例上稱為 paths)來注入路由(請求)類型的穩定性。
- 所有 API 返回 RxJS Observable
- 核心是框架獨立的(不依賴於 Next.js)
- Next.js 專用功能分離為 @byeolnaerim/typed-rx-http/next 入口點 → 只有在導入 /next 時才需要 Next.js
- RSocket 專用功能分離為 @byeolnaerim/typed-rx-http/rsocket 入口點 → 只有在導入 /rsocket 時才需要 RSocket 套件
安裝
npm
npm i @byeolnaerim/typed-rx-http rxjs入口點
Core (框架獨立)
import {
createHttpClient,
createHeaderStore,
createCsrCache,
createSessionAuth,
HttpResponseError,
isHttpResponseError,
type ServiceArguments,
type CacheForService,
} from "@byeolnaerim/typed-rx-http";Next.js 適配器(可選)
import {
callApiSsrCache,
redirectToUnauthorizedOnServer401,
} from "@byeolnaerim/typed-rx-http/next";在不使用 Next.js 的專案中,請勿導入 /next。
RSocket 適配器(可選)
僅在使用 RSocket 的專案中安裝 peer 套件。
npm i rsocket-core rsocket-types rsocket-websocket-clientimport { createRSocketApi } from "@byeolnaerim/typed-rx-http/rsocket";
const api = createRSocketApi("ws://localhost:7000/rsocket", jwt);
api.mono<User>("user.findById", { id: 1 }).subscribe((user) => {
console.log(user);
});
api.stream<Notification>("notification.stream").subscribe((notification) => {
console.log(notification);
});在不使用 RSocket 的專案中,請勿導入 /rsocket。
核心用法
1) 準備 Paths 類型(通常是 OpenAPI paths)
createHttpClient<Paths>() 的 Paths 表示“請求規範(路由)”的類型。文檔中慣例上稱為 paths,但不一定需要是 OpenAPI/Swagger,也不一定需要名稱為 paths。
不過核心內部使用 OpenApiPathsLike 約束,因此,Paths 必須類似於 OpenAPI paths 的形式。
- 最上層鍵:URL 路徑字符串(例如:"/users/{id}")
- 下層鍵:HTTP 方法(get/post/put/delete/patch …)
- 每個方法內存在 parameters.query/path/header/cookie、requestBody、responses 等字段(或 never)
核心主要參考上述結構中的以下字段來構建 ServiceArguments 的類型。
- url: keyof Paths
- method: keyof Paths[url]
- queryString: parameters.query
- pathVariable: parameters.path
- body: requestBody
例如:openapi-typescript 的輸出通常如下所示(部分簡化)。
export interface paths {
"/test/get-hello-world": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
content: {
"application/json": components["schemas"]["TestResponse"];
};
};
};
};
};
}// aliasing: OpenAPI `paths` -> `Paths`
import type { paths as Paths } from "./@types/ApiTypes";2) 創建 HeaderStore
HeaderStore 是一個簡單的內存存儲,用於管理 CSR 的基本標頭。
import { createHeaderStore } from "@byeolnaerim/typed-rx-http";
export const headerStore = createHeaderStore({
"Content-Type": "application/json",
});3) 創建 HTTP 客戶端
- headerStore 是可選的,但建議在 CSR 中使用基本標頭/會話認證時包含它。
- headersProvider 用於需要每次請求計算標頭的情況,如 SSR/多租戶。
import { createHttpClient } from "@byeolnaerim/typed-rx-http";
import type { paths as Paths } from "./@types/ApiTypes";
const apiUrl = process.env.NEXT_PUBLIC_API_URL!;
export const client = createHttpClient<Paths>({
baseUrl: apiUrl,
headerStore,
});4) API 調用(類型安全)
請求類型(url/method/pathVariable/queryString/body)由用戶注入到 createHttpClient<Paths>() 的類型(慣例上為 OpenAPI paths)決定。響應類型由調用者在 callApi<R>() 中選擇的泛型 R 決定(核心不會自動推斷 responses)。
export const getAdminList = () => {
return client.callApi<{ items: any[]; total: number }>({
url: "/api/account/search/admin/get-list",
method: "get",
queryString: { pageNumber: 1, pageSize: 20 },
});
};響應包裝(ResponseWrapper)— 可選
此庫不強制響應包裝。可以根據 API 自行選擇泛型響應形式。
包裝的響應
type ResponseWrapper<T> = { data: T; message?: string; resultType?: string };
export const bidCreate = (body: unknown) => {
return client.callApi<ResponseWrapper<{ requestId: string }>>({
url: "/api/bid/create/bid/request",
method: "post",
body,
});
};未包裝的響應
export const bidCreate = (body: unknown) => {
return client.callApi<{ requestId: string }>({
url: "/api/bid/create/bid/request",
method: "post",
body,
});
};流式傳輸(NDJSON)
當服務器返回 NDJSON(每行一個 JSON)時,使用 callApiStream。如果沒有 Accept 標頭,則默認設置為 application/x-ndjson。
export const streamEvents = () => {
return client.callApiStream<{ eventName: string; content: unknown }>({
url: "/api/event/stream",
method: "get",
// headers: { Accept: "application/x-ndjson" }, // 선택
});
};CSR 緩存(客戶端緩存)
createCsrCache<CacheName>() 提供的功能:
callApiCsrCache(callApiFn, serviceArgs, cacheOptions)- removeCsrCache(cacheName) — 支持類型緩存名 + 字符串
import { createCsrCache } from "@byeolnaerim/typed-rx-http";
import type { paths as Paths } from "./@types/ApiTypes";
type CacheNames = "adminList" | "profile";
const csrCache = createCsrCache<CacheNames>();
export const callApiClientCache = <R>(
args: ServiceArguments<Paths, any, any, R>,
cache: { cacheName: CacheNames; cacheTime: number; cacheSize?: number },
) => csrCache.callApiCsrCache(client.callApi, args as any, cache as any);
// 무효화
csrCache.removeCsrCache("adminList"); // 타입
csrCache.removeCsrCache("any-prefix"); // 문자열基於會話的認證插件(可選)
createSessionAuth 將會話認證邏輯從核心中分離,作為選項附加或移除。
操作:
- 將 Authorization 保持在 headerStore 中
- 使用 ensureToken$() 同步令牌(/api/auth/token)
- 當發生 401 時,嘗試刷新一次(/api/auth/token/refresh),然後重試原請求
- 刷新失敗時,登出(/api/auth/logout)並傳遞錯誤
- 登錄狀態變更由 onLoginChange 回調在外部處理
import { createSessionAuth } from "@byeolnaerim/typed-rx-http";
const auth = createSessionAuth({
headerStore,
onLoginChange: (loggedIn) => {
// 예: setLogin(loggedIn) 같은 외부 상태 업데이트(선택)
},
// tokenUrl / refreshUrl / logoutUrl 커스터마이징 가능
});
export const secureCall = () => {
return client
.callApi<{ ok: true }>({ url: "/api/secure", method: "get" })
.pipe(auth.withSessionAuth());
};如果只需要令牌同步而不需要刷新/重試:
return client.callApi(...).pipe(auth.withEnsureToken());錯誤處理
如果不是 2xx,則拋出 HttpResponseError(包括 status、response、args、data)。
舊版兼容:如果錯誤主體為 { resultType: ... } 形式,則直接拋出該對象。
import { isHttpResponseError } from "@byeolnaerim/typed-rx-http";
client.callApi(...).subscribe({
error: (e) => {
if (isHttpResponseError(e)) {
console.log(e.status, e.data);
}
},
});Next.js 適配器(/next)
redirectToUnauthorizedOnServer401
redirectToUnauthorizedOnServer401 是在 Next.js(App Router)SSR 環境中發生 401 時執行重定向的基本實現(便捷函數)。
import { redirectToUnauthorizedOnServer401 } from "@byeolnaerim/typed-rx-http/next";操作規則(固定):
- 重定向目標:/unauthorized
- queryString:redirect_uri=<當前頁面> + logout=true
- 當前頁面從 x-page-url 標頭中讀取(如果不存在則為 /)
也就是說,只有當上述路徑/查詢規則與項目匹配時,才可以直接使用。如果路徑不同或查詢規則不同,則可以像下面這樣直接實現 onServer401 並注入。
import { headers } from "next/headers";
import { redirect } from "next/navigation";
export const onServer401 = async () => {
const h = await headers();
const pageUrl = h.get("x-page-url") || "/";
redirect(`/login?next=${encodeURIComponent(pageUrl)}`);
};callApiSsrCache
Next 的 next/cache(unstable_cache) 基礎 SSR 快取助手。
- GET + cacheTime > 0 → 強制快取 + 重新驗證
- 其他 → no-store
- 透過 headersProvider 注入每個請求的 Cookie / Authorization
- 當發生 401 時,如果有 onServer401 則執行(通常是 redirect())
import type { paths as Paths } from "./@types/ApiTypes";
import { firstValueFrom } from "rxjs";
import {
callApiSsrCache,
redirectToUnauthorizedOnServer401,
} from "@byeolnaerim/typed-rx-http/next";
const apiUrl = process.env.NEXT_PUBLIC_API_URL!;
const headersProvider = async () => {
const { cookies } = await import("next/headers");
const cookieStore = await cookies();
const cookieHeader = cookieStore
.getAll()
.map((c) => `${c.name}=${c.value}`)
.join("; ");
const accessToken = cookieStore.get("accessToken")?.value;
return {
...(cookieHeader ? { Cookie: cookieHeader } : {}),
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
};
};
const obs$ = callApiSsrCache<Paths, { ok: true }, "adminList">({
baseUrl: apiUrl,
serviceArguments: { url: "/api/secure", method: "get" },
cacheForService: { cacheName: "adminList", cacheTime: 10_000 },
headersProvider,
onServer401: redirectToUnauthorizedOnServer401,
});
const res = await firstValueFrom(obs$);Next.js 整合範例:專案 rxjsHttpService/commonService 全部代碼
以下 rxjsHttpService.ts 是後續 generator 範例的 commonServiceFile 所指的專案共用 HTTP adapter。這不是庫生成的文件,而是專案直接管理,並將 core client + session auth + CSR cache + SSR cache helper 一併匯出。以下是全部代碼。
rxjsHttpService.ts
tsimport {
createCsrCache,
createHeaderStore,
createHttpClient,
createSessionAuth,
} from "@byeolnaerim/typed-rx-http";
import type {
CacheForService,
ServiceArguments,
} from "@byeolnaerim/typed-rx-http";
import { defer, Observable } from "rxjs";
import { setLogin } from "@ui/handler/hooks/useAccounts";
import type { paths } from "./@types/ApiTypes";
import type { CacheNames } from "./@types/CacheNames";
export const redirectToUnauthorizedOnServer401 = async () => {
const { headers } = await import("next/headers");
const { redirect } = await import("next/navigation");
const h = await headers();
const pageUrl = h.get("x-page-url") || "/";
const redirectUri = encodeURIComponent(pageUrl);
redirect(`/unauthorized?redirect_uri=${redirectUri}&logout=true`);
};
export const headerStore = createHeaderStore({
"Content-Type": "application/json",
});
const headersProvider = async () => {
if (typeof window !== "undefined") {
/**
* 라이브러리쪽 코드가
* if (headersProvider) return headersProvider();
* if (headerStore) return headerStore.get();
* 이렇게 되어있음 같이 못씀, 방향자체는 맞음. 라이브러리쪽에서 marge해버리면 csr/ssr 구분못함
*/
return headerStore.get();
}
const { cookies } = await import("next/headers");
const cookieStore = await cookies();
const cookieHeader = cookieStore
.getAll()
.map((c) => `${c.name}=${c.value}`)
.join("; ");
const authorization = cookieStore.get("Authorization")?.value;
const accessToken = cookieStore.get("accessToken")?.value;
return {
...(cookieHeader ? { Cookie: cookieHeader } : {}),
...(authorization
? {
Authorization: authorization.startsWith("Bearer ")
? authorization
: `Bearer ${authorization}`,
}
: {}),
...(!authorization && accessToken
? {
Authorization: accessToken.startsWith("Bearer ")
? accessToken
: `Bearer ${accessToken}`,
}
: {}),
};
};
export const service = createHttpClient<paths>({
baseUrl: process.env.NEXT_PUBLIC_API_URL || "",
onServer401: redirectToUnauthorizedOnServer401,
defaultErrorMessage: () => {
return "알 수 없는 오류가 발생했습니다.";
},
headerStore,
headersProvider,
dropAuthWhenCacheControl: true,
});
const sessionAuth = createSessionAuth({
headerStore,
tokenUrl: "/api/auth/token",
refreshUrl: "/api/auth/token/refresh",
logoutUrl: "/api/auth/logout",
onLoginChange: (loggedIn) => {
if (typeof window !== "undefined") {
setLogin(loggedIn);
}
},
});
export const callApi = <
R,
TPath extends keyof paths = keyof paths,
TMethod extends keyof paths[TPath] & string = keyof paths[TPath] & string,
>(
serviceArguments: ServiceArguments<paths, TPath, TMethod, R>,
): Observable<R> => {
return defer(() =>
service.callApi<R, TPath, TMethod>(serviceArguments),
).pipe(sessionAuth.withSessionAuth());
};
export const callApiStream = <
R,
TPath extends keyof paths = keyof paths,
TMethod extends keyof paths[TPath] & string = keyof paths[TPath] & string,
>(
serviceArguments: ServiceArguments<paths, TPath, TMethod, R>,
): Observable<R> => {
return defer(() =>
service.callApiStream<R, TPath, TMethod>(serviceArguments),
).pipe(sessionAuth.withSessionAuth());
};
// CSR cache
const csrCache = createCsrCache<CacheNames>();
export const callApiClientCache = <
R,
TPath extends keyof paths = keyof paths,
TMethod extends keyof paths[TPath] & string = keyof paths[TPath] & string,
>(
serviceArguments: ServiceArguments<paths, TPath, TMethod, R>,
cacheForService: CacheForService<CacheNames>,
): Observable<R> => {
return csrCache.callApiCsrCache(callApi, serviceArguments, cacheForService);
};
export const removeCsrCache = csrCache.removeCsrCache;
export const callApiServerCache = <
R,
TPath extends keyof paths = keyof paths,
TMethod extends keyof paths[TPath] & string = keyof paths[TPath] & string,
>(
serviceArguments: ServiceArguments<paths, TPath, TMethod, R>,
cacheForService: CacheForService<CacheNames>,
): Observable<R> => {
if (typeof window !== "undefined") {
return callApiClientCache(serviceArguments, cacheForService);
}
return new Observable<R>((subscriber) => {
let closed = false;
import("@byeolnaerim/typed-rx-http/next")
.then(({ callApiSsrCache }) => {
if (closed) {
return;
}
const subscription = callApiSsrCache<paths, R, CacheNames>({
baseUrl: process.env.NEXT_PUBLIC_API_URL || "",
serviceArguments: serviceArguments as ServiceArguments<
paths,
keyof paths,
keyof paths[keyof paths] & string,
R
>,
cacheForService,
headersProvider,
onServer401: redirectToUnauthorizedOnServer401,
}).subscribe(subscriber);
subscriber.add(() => {
closed = true;
subscription.unsubscribe();
});
})
.catch((error) => {
if (!closed) {
subscriber.error(error);
}
});
return () => {
closed = true;
};
});
};API 參考 (core)
createHttpClient<Paths>(options)
返回:
callApi<R>(args): Observable<R>callApiStream<RChunk>(args): Observable<RChunk>uploadFile({ file, url, ifNoneMatch?, headers? }): Observable<Response>createSSEObservable<R>(args): Observable<R>
選項:
baseUrl: stringheaderStore?: HeaderStoreheadersProvider?: () => Record<string, string> | Promise<Record<string, string>>- dropAuthWhenCacheControl?: boolean (預設值: true)
onServer401?: () => void | Promise<void>
createHeaderStore(initial?)
get(), set(), merge(), remove(), clear()
createCsrCache<CacheName>()
callApiCsrCache(callApiFn, serviceArgs, cacheForService)- removeCsrCache(cacheName) (類型 + 字串)
createSessionAuth(options)
withSessionAuth(), withEnsureToken()ensureToken$(), refreshToken$(), logout$()
運行時需求
- fetch / Response API 使用 (rxjs/fetch)
- 串流 (NDJSON) 需要 ReadableStream + TextDecoder
- SSE 需要 EventSource
大多數最新瀏覽器和 Next.js 運行時都內建支援。在自訂 Node 運行時中可能需要 polyfill。
自動 Node 腳本:OpenAPI/Swagger 代碼生成(選擇性)
此套件除了運行時 HTTP 客戶端外,還提供一個從 OpenAPI/Swagger JSON 生成類型/服務代碼的 Node 腳本。此腳本為選擇性功能。
一般的 @byeolnaerim/typed-rx-http, /next, /rsocket 使用者無需執行此腳本,也不需要安裝 openapi-typescript。
import {
connectToSwaggerEventStream,
generateSwaggerFromHttp,
generateSwaggerFromFile,
} from "@byeolnaerim/typed-rx-http/auto-node-script/openapi";依賴隔離
OpenAPI 類型生成需要 openapi-typescript CLI。但此套件不會將 openapi-typescript 放入一般依賴中。
@byeolnaerim/typed-rx-http 庫本身使用 devDependencies.typescript 的 TypeScript 6.0.3。typed-rx-http, /next, /rsocket 入口點和庫構建保持此 TypeScript 6.0.3 標準。
不過 openapi-typescript 可能仍然需要特定的 TypeScript 5.x 版本,因此 OpenAPI 自動 Node 腳本僅在單獨的 npx 臨時執行環境中與 openapi-typescript 和 [email protected] 一起執行。此臨時執行環境不會更改庫的 devDependencies.typescript 6.0.3,也不會使用用戶專案中安裝的 typescript 或 openapi-typescript 版本。
預設值是在自動腳本執行時僅生成並執行以下命令。
npx -y -p openapi-typescript@latest -p [email protected] openapi-typescript swagger.json --output ./src/handler/service/@types/ApiTypes.d.ts因此使用方法本身不會改變。用戶仍然可以像以前一樣調用自動 Node 腳本,並且在 OpenAPI 類型生成階段僅使用隔離的 TypeScript 5.9.3 環境。不使用自動腳本的用戶不會受到 openapi-typescript 或 TypeScript 5.9.3 的任何束縛。
如有需要,可以直接使用 openApiTypescriptCommand 鎖定命令。
generateSwaggerFromFile({
inputFile: "./swagger.json",
openApiTypescriptCommand:
"npx -y -p [email protected] -p [email protected] openapi-typescript ./swagger.json --output ./src/handler/service/@types/ApiTypes.d.ts",
});或者僅更改組成基本命令的套件版本。
generateSwaggerFromFile({
inputFile: "./swagger.json",
openApiTypescriptPackage: "[email protected]",
openApiTypescriptTypescriptPackage: "[email protected]",
});生成的檔案
預設會生成以下檔案。
swagger.json
src/handler/service/@types/ApiTypes.d.ts
src/handler/service/auto/*Service.ts
src/handler/service/@types/auto/*Types.ts
src/handler/service/apiUnionArrays.tsapiUnionArrays.ts 不僅生成 OpenAPI schema enum,還生成 query、path、header、cookie 參數的 enum 作為常數陣列。也處理陣列 query 參數的 items.enum。
EventStream 監控
connectToSwaggerEventStream({
hostname: "localhost",
port: 8788,
path: "/oauth2/for-local/get-swagger",
serviceDir: "./src/handler/service",
typesDir: "./src/handler/service/@types",
commonServiceFile: "./src/handler/service/rxjsHttpService.ts",
});HTTP 單次請求
await generateSwaggerFromHttp({
hostname: "localhost",
port: 8788,
path: "/oauth2/for-local/get-swagger",
});從本地檔案生成
generateSwaggerFromFile({
inputFile: "./swagger.json",
});現有專案整合範例:WebFlux + Swagger 自動生成
從這裡開始是後端 Swagger 與自動生成服務一起使用的專案整合範例。上面的 Core 使用方法和選擇功能可以僅使用 typed-rx-http 本身,而下面的流程則是在使用 Swagger 基礎的服務自動生成的專案中額外應用。
1. 在後端撰寫 REST 端點
後端代碼像平常一樣撰寫。在這個例子中,使用路徑變數接收名稱,使用查詢參數接收消息 Mono並作為回應。
TypedRxHttpExampleRouter.java
java@Bean
public RouterFunction<ServerResponse> typedRxHttpExample(
TypedRxHttpExampleHandler handler
) {
return route()
.GET("/typed-rx-http/example/{name}", handler::getExample)
.build();
}2. 在 Swagger 中生成前端服務
接收後端的 swagger.json生成類型和服務檔案。這個操作可以在啟動開發伺服器時呼叫一次,或連接到監視腳本。
generateSwagger.cjs
jsconst {
generateSwaggerFromHttp,
} = require("@byeolnaerim/typed-rx-http/auto-node-script/openapi");
generateSwaggerFromHttp({
hostname: "localhost",
port: 8123,
path: "/for-local/get-swagger",
swaggerFile: "./swagger.json",
serviceDir: "./src/handler/service",
typesDir: "./src/handler/service/@types",
typesFile: "./src/handler/service/@types/ApiTypes.d.ts",
commonServiceFile:
"./src/handler/service/rxjsHttpService.ts",
});3. 使用生成的函數發送請求
可以在下面 前端代碼和 後端代碼進行更改。 結果按下後會在右側打開執行畫面。更改值後,請按請求按鈕。
TypedRxHttpRequestExample.tsx
tsximport { byName } from "@handler/service/auto/TypedRxHttpExampleService";
import { firstValueFrom } from "rxjs";
const response = await firstValueFrom(
byName({
path: {
name: name.trim(),
},
params: {
message: message.trim() || undefined,
},
}),
);
setResponse(response);