快速開始

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
bash
npm i @byeolnaerim/typed-rx-http rxjs

入口點

Core (框架獨立)
ts
import {
  createHttpClient,
  createHeaderStore,
  createCsrCache,
  createSessionAuth,
  HttpResponseError,
  isHttpResponseError,
  type ServiceArguments,
  type CacheForService,
} from "@byeolnaerim/typed-rx-http";
Next.js 適配器(可選)
ts
import {
  callApiSsrCache,
  redirectToUnauthorizedOnServer401,
} from "@byeolnaerim/typed-rx-http/next";

在不使用 Next.js 的專案中,請勿導入 /next。

RSocket 適配器(可選)

僅在使用 RSocket 的專案中安裝 peer 套件。

bash
npm i rsocket-core rsocket-types rsocket-websocket-client
ts
import { 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 的輸出通常如下所示(部分簡化)。

ts
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"];
          };
        };
      };
    };
  };
}
ts
// aliasing: OpenAPI `paths` -> `Paths`
import type { paths as Paths } from "./@types/ApiTypes";

2) 創建 HeaderStore

HeaderStore 是一個簡單的內存存儲,用於管理 CSR 的基本標頭。

ts
import { createHeaderStore } from "@byeolnaerim/typed-rx-http";

export const headerStore = createHeaderStore({
  "Content-Type": "application/json",
});

3) 創建 HTTP 客戶端

  • headerStore 是可選的,但建議在 CSR 中使用基本標頭/會話認證時包含它。
  • headersProvider 用於需要每次請求計算標頭的情況,如 SSR/多租戶。
ts
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)。

ts
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 自行選擇泛型響應形式。

包裝的響應
ts
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,
  });
};
未包裝的響應
ts
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。

ts
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) — 支持類型緩存名 + 字符串
ts
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 回調在外部處理
ts
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());
};

如果只需要令牌同步而不需要刷新/重試:

ts
return client.callApi(...).pipe(auth.withEnsureToken());

錯誤處理

如果不是 2xx,則拋出 HttpResponseError(包括 status、response、args、data)。

舊版兼容:如果錯誤主體為 { resultType: ... } 形式,則直接拋出該對象。

ts
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 時執行重定向的基本實現(便捷函數)。

ts
import { redirectToUnauthorizedOnServer401 } from "@byeolnaerim/typed-rx-http/next";

操作規則(固定):

  • 重定向目標:/unauthorized
  • queryString:redirect_uri=<當前頁面> + logout=true
  • 當前頁面從 x-page-url 標頭中讀取(如果不存在則為 /)

也就是說,只有當上述路徑/查詢規則與項目匹配時,才可以直接使用。如果路徑不同或查詢規則不同,則可以像下面這樣直接實現 onServer401 並注入。

ts
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())
ts
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
ts
import {
	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: string
  • headerStore?: HeaderStore
  • headersProvider?: () => 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。

ts
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 版本。

預設值是在自動腳本執行時僅生成並執行以下命令。

bash
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 鎖定命令。

ts
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",
});

或者僅更改組成基本命令的套件版本。

ts
generateSwaggerFromFile({
  inputFile: "./swagger.json",
  openApiTypescriptPackage: "[email protected]",
  openApiTypescriptTypescriptPackage: "[email protected]",
});
生成的檔案

預設會生成以下檔案。

text
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.ts

apiUnionArrays.ts 不僅生成 OpenAPI schema enum,還生成 query、path、header、cookie 參數的 enum 作為常數陣列。也處理陣列 query 參數的 items.enum。

EventStream 監控
ts
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 單次請求
ts
await generateSwaggerFromHttp({
  hostname: "localhost",
  port: 8788,
  path: "/oauth2/for-local/get-swagger",
});
從本地檔案生成
ts
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
js
const {
  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
tsx
import { 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);
© 2026 Byeolnaerim. 版權所有。介紹隱私政策