Quick Start

Type-safe HTTP client based on RxJS for TypeScript + (optional) Next.js/RSocket adapter.


Core Features

  • Inject OpenAPI-style Paths types (conventionally paths) obtained from Swagger/OpenAPI / AsyncAPI derived schemas / custom contracts to ensure route (request) type safety.
  • All APIs return RxJS Observables.
  • Core is framework-independent (no dependency on Next.js).
  • Next.js specific features are separated into the @byeolnaerim/typed-rx-http/next entry point → Next.js is only needed when importing /next.
  • RSocket specific features are separated into the @byeolnaerim/typed-rx-http/rsocket entry point → RSocket package is only needed when importing /rsocket.

Installation

npm
bash
npm i @byeolnaerim/typed-rx-http rxjs

Entry Point

Core (framework-independent)
ts
import {
  createHttpClient,
  createHeaderStore,
  createCsrCache,
  createSessionAuth,
  HttpResponseError,
  isHttpResponseError,
  type ServiceArguments,
  type CacheForService,
} from "@byeolnaerim/typed-rx-http";
Next.js Adapter (optional)
ts
import {
  callApiSsrCache,
  redirectToUnauthorizedOnServer401,
} from "@byeolnaerim/typed-rx-http/next";

Do not import /next in projects that do not use Next.js.

RSocket Adapter (optional)

Only install peer packages in projects that use RSocket.

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);
});

Do not import /rsocket in projects that do not use RSocket.

Core Usage

1) Prepare Paths type (usually OpenAPI paths).

The Paths in createHttpClient<Paths>() represents the "request specification (route)" type. It is conventionally referred to as paths in the documentation, but it does not necessarily have to be OpenAPI/Swagger or named paths.

However, since the core internally uses the OpenApiPathsLike constraint, Paths should resemble OpenAPI paths as shown below.

  • Top-level key: URL path string (e.g., "/users/{id}")
  • Sub-key: HTTP method (get/post/put/delete/patch …)
  • Each method contains fields such as parameters.query/path/header/cookie, requestBody, responses (or never).

The core primarily references the fields below to construct the type of ServiceArguments.

  • url: keyof Paths
  • method: keyof Paths[url]
  • queryString: parameters.query
  • pathVariable: parameters.path
  • body: requestBody

Example: The output of openapi-typescript usually follows the format below (some abbreviations).

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) Create HeaderStore

HeaderStore is a simple in-memory store for managing default headers in CSR.

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

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

3) Create HTTP client

  • headerStore is optional, but it is recommended to include it if you want to use default headers/session authentication in CSR.
  • headersProvider is used when header calculation is needed for each request, such as in SSR/multi-tenant scenarios.
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 call (type safe)

The request type (url/method/pathVariable/queryString/body) is determined by the type injected into createHttpClient<Paths>() (conventionally OpenAPI paths). The response type is selected by the caller as a generic R in callApi<R>() (the core does not automatically infer from 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 },
  });
};

Response Wrapping (ResponseWrapper) — optional

This library does not enforce response wrapping. You can choose the response format generically for each API.

Wrapped response
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,
  });
};
Unwrapped response
ts
export const bidCreate = (body: unknown) => {
  return client.callApi<{ requestId: string }>({
    url: "/api/bid/create/bid/request",
    method: "post",
    body,
  });
};

Streaming (NDJSON)

Use callApiStream when the server sends NDJSON (one JSON per line). If there is no Accept header, application/x-ndjson is set as the default.

ts
export const streamEvents = () => {
  return client.callApiStream<{ eventName: string; content: unknown }>({
    url: "/api/event/stream",
    method: "get",
    // headers: { Accept: "application/x-ndjson" }, // 선택
  });
};

CSR Cache (Client Cache)

Features provided by createCsrCache<CacheName>():

  • callApiCsrCache(callApiFn, serviceArgs, cacheOptions)
  • removeCsrCache(cacheName) — supports both type cache names and strings.
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"); // 문자열

Session-based authentication plugin (optional)

createSessionAuth separates session authentication logic from the core, allowing it to be attached or detached as an option.

Operation:

  • Maintain Authorization in headerStore
  • Synchronize token with ensureToken$() (/api/auth/token)
  • On 401, attempt refresh once (/api/auth/token/refresh) and retry the original request.
  • On refresh failure, logout (/api/auth/logout) and pass the error.
  • Changes in login status are handled externally via the onLoginChange callback.
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());
};

If only token synchronization is needed without refresh/retry:

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

Error handling

Throws HttpResponseError if not 2xx (includes status, response, args, data).

Legacy compatibility: If the error body is in the form { resultType: ... }, that object is thrown as is.

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

client.callApi(...).subscribe({
  error: (e) => {
    if (isHttpResponseError(e)) {
      console.log(e.status, e.data);
    }
  },
});

Next.js adapter (/next)

redirectToUnauthorizedOnServer401

redirectToUnauthorizedOnServer401 is the default implementation (utility function) for performing a redirect when a 401 occurs in a Next.js (App Router) SSR environment.

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

Operation Rules (Fixed):

  • Redirect target: /unauthorized
  • queryString: redirect_uri=<current page> + logout=true
  • The current page is read from the x-page-url header (or / if not present)

In other words, use the above path/query rules as is only when they match your project. If the path is different or the query rules are different, you can implement onServer401 directly as shown below.

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's next/cache (unstable_cache) based SSR cache helper.

  • GET + cacheTime > 0 → force-cache + revalidate
  • Otherwise → no-store
  • Inject per-request Cookie / Authorization with headersProvider
  • If onServer401 exists when a 401 occurs, it will be executed (usually 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 integration example: Full code of project rxjsHttpService/commonService.

The rxjsHttpService.ts below is the common HTTP adapter for the project referred to by the commonServiceFile in the subsequent generator example. It is managed directly by the project, not generated by the library, and exports core client + session auth + CSR cache + SSR cache helper in one place. Below is the full code.

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 Reference (core)

createHttpClient<Paths>(options)

Returns:

  • callApi<R>(args): Observable<R>
  • callApiStream<RChunk>(args): Observable<RChunk>
  • uploadFile({ file, url, ifNoneMatch?, headers? }): Observable<Response>
  • createSSEObservable<R>(args): Observable<R>

Options:

  • baseUrl: string
  • headerStore?: HeaderStore
  • headersProvider?: () => Record<string, string> | Promise<Record<string, string>>
  • dropAuthWhenCacheControl?: boolean (default: true)
  • onServer401?: () => void | Promise<void>
createHeaderStore(initial?)

get(), set(), merge(), remove(), clear()

createCsrCache<CacheName>()
  • callApiCsrCache(callApiFn, serviceArgs, cacheForService)
  • removeCsrCache(cacheName) (type + string)
createSessionAuth(options)
  • withSessionAuth(), withEnsureToken()
  • ensureToken$(), refreshToken$(), logout$()

Runtime Requirements

  • Use fetch / Response API (rxjs/fetch)
  • Streaming (NDJSON) requires ReadableStream + TextDecoder.
  • SSE requires EventSource.

Most modern browsers and the Next.js runtime provide this by default. Custom Node runtimes may require polyfills.

Auto Node Script: OpenAPI/Swagger code generation (optional)

This package provides a Node script that generates type/service code from OpenAPI/Swagger JSON, separate from the runtime HTTP client. This script is optional.

General users of @byeolnaerim/typed-rx-http, /next, /rsocket do not need to run this script and do not need to install openapi-typescript.

ts
import {
  connectToSwaggerEventStream,
  generateSwaggerFromHttp,
  generateSwaggerFromFile,
} from "@byeolnaerim/typed-rx-http/auto-node-script/openapi";
Dependency Isolation

The openapi-typescript CLI is required for OpenAPI type generation. However, this package does not include openapi-typescript in the regular dependencies.

The @byeolnaerim/typed-rx-http library itself uses TypeScript 6.0.3 in devDependencies.typescript. The typed-rx-http, /next, and /rsocket entry points and library builds maintain this TypeScript 6.0.3 standard.

However, openapi-typescript may still require a specific TypeScript 5.x version, so the OpenAPI auto node script runs openapi-typescript and [email protected] in a separate npx temporary execution environment. This temporary execution environment does not change the library's devDependencies.typescript 6.0.3 and does not use the typescript or openapi-typescript versions installed in the user's project.

The default is to generate and execute the command below only at the time of auto script execution.

bash
npx -y -p openapi-typescript@latest -p [email protected] openapi-typescript swagger.json --output ./src/handler/service/@types/ApiTypes.d.ts

Therefore, the usage itself does not change. You can call the auto node script as before, and only a separate TypeScript 5.9.3 environment is used during the OpenAPI type generation phase. Users who do not use the auto script are not tied to openapi-typescript or TypeScript 5.9.3 at all.

If needed, you can directly fix the command with 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",
});

Alternatively, you can change only the package version that configures the default command.

ts
generateSwaggerFromFile({
  inputFile: "./swagger.json",
  openApiTypescriptPackage: "[email protected]",
  openApiTypescriptTypescriptPackage: "[email protected]",
});
Generated Files

The default configuration generates the files below.

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 generates constant arrays for OpenAPI schema enums as well as query, path, header, and cookie parameter enums. It also processes the items.enum of array query parameters.

EventStream Monitoring
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",
});
Single HTTP Request
ts
await generateSwaggerFromHttp({
  hostname: "localhost",
  port: 8788,
  path: "/oauth2/for-local/get-swagger",
});
Generated from Local Files
ts
generateSwaggerFromFile({
  inputFile: "./swagger.json",
});

Existing Project Integration Example: WebFlux + Swagger Auto Generation

From here on, it is an integration example of a project using both backend Swagger and auto-generated services. The core usage and optional features above can be used solely with typed-rx-http, and the flow below is additionally applied in projects that use Swagger-based service auto generation.

1. Write a REST endpoint in the backend.

Write the backend code as usual. In this example, it receives a name as a path variable and a message as a query parameter. Monoand responds.

TypedRxHttpExampleRouter.java
java
@Bean
public RouterFunction<ServerResponse> typedRxHttpExample(
  TypedRxHttpExampleHandler handler
) {
  return route()
    .GET("/typed-rx-http/example/{name}", handler::getExample)
    .build();
}

2. Generate front services from Swagger.

Receive the backend's swagger.jsonto generate type and service files. This can be called once when running the development server or connected via a watch script.

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. Send requests using the generated functions.

You can try changing Front codeand Backend codebelow. ResultClicking will open the execution screen on the right. Change the values and press the request button.

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. All rights reserved.IntroductionPrivacy Policy