@routar/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Kyungbae Min
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,11 @@
1
+ # @routar/core
2
+
3
+ Core package for [routar](https://github.com/kbmin1129/routar) — endpoint definitions, typed router, API client factory, and middleware system.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @routar/core
9
+ ```
10
+
11
+ See the [main README](https://github.com/kbmin1129/routar) for full documentation.
package/dist/index.cjs ADDED
@@ -0,0 +1,200 @@
1
+ 'use strict';
2
+
3
+ // src/define-router.ts
4
+ function defineRouter(prefix, endpoints) {
5
+ return { prefix, endpoints };
6
+ }
7
+
8
+ // src/define-endpoint.ts
9
+ function endpoint(spec) {
10
+ return spec;
11
+ }
12
+
13
+ // src/utils/path.ts
14
+ function joinPaths(...segments) {
15
+ const joined = segments.filter((s) => s !== "").join("/").replace(/\/+/g, "/");
16
+ return joined.endsWith("/") && joined.length > 1 ? joined.slice(0, -1) : joined || "/";
17
+ }
18
+ function resolvePath(pathTemplate, params) {
19
+ if (!params) return pathTemplate;
20
+ return pathTemplate.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, key) => {
21
+ const value = params[key];
22
+ if (value == null) throw new Error(`Missing path parameter: ${key}`);
23
+ return encodeURIComponent(String(value));
24
+ });
25
+ }
26
+
27
+ // src/utils/validate.ts
28
+ var ValidationError = class extends Error {
29
+ constructor(message, cause) {
30
+ super(message);
31
+ this.cause = cause;
32
+ this.name = "ValidationError";
33
+ if (cause !== void 0) {
34
+ Object.defineProperty(this, "cause", {
35
+ value: cause,
36
+ writable: true,
37
+ enumerable: true
38
+ });
39
+ }
40
+ }
41
+ };
42
+
43
+ // src/create-api.ts
44
+ function createApi(executor, routerOrPrefixOrEndpoints, endpointsArg) {
45
+ let prefix;
46
+ let endpoints;
47
+ if (typeof routerOrPrefixOrEndpoints === "string") {
48
+ prefix = routerOrPrefixOrEndpoints;
49
+ if (!endpointsArg) throw new Error("endpoints is required when prefix is provided");
50
+ endpoints = endpointsArg;
51
+ } else if ("prefix" in routerOrPrefixOrEndpoints && "endpoints" in routerOrPrefixOrEndpoints) {
52
+ prefix = routerOrPrefixOrEndpoints.prefix;
53
+ endpoints = routerOrPrefixOrEndpoints.endpoints;
54
+ } else {
55
+ prefix = "";
56
+ endpoints = routerOrPrefixOrEndpoints;
57
+ }
58
+ return buildClient(executor, prefix, endpoints);
59
+ }
60
+ function buildClient(executor, prefix, endpoints) {
61
+ const client = {};
62
+ for (const [key, entry] of Object.entries(endpoints)) {
63
+ if ("prefix" in entry && "endpoints" in entry) {
64
+ const nested = entry;
65
+ client[key] = buildClient(executor, joinPaths(prefix, nested.prefix), nested.endpoints);
66
+ } else {
67
+ const spec = entry;
68
+ client[key] = async (params = {}, signal) => {
69
+ let validatedParams = params;
70
+ if (spec.request) {
71
+ try {
72
+ validatedParams = spec.request.parse(params);
73
+ } catch (err) {
74
+ throw new ValidationError("Request validation failed", err);
75
+ }
76
+ }
77
+ const url = resolvePath(
78
+ joinPaths(prefix, spec.path),
79
+ validatedParams?.path
80
+ );
81
+ const raw = await executor.execute({
82
+ method: spec.method,
83
+ url,
84
+ params: validatedParams?.query,
85
+ body: validatedParams?.body,
86
+ signal
87
+ });
88
+ let validated;
89
+ try {
90
+ validated = spec.response.parse(raw);
91
+ } catch (err) {
92
+ throw new ValidationError("Response validation failed", err);
93
+ }
94
+ if (spec.adapter) {
95
+ return spec.adapter(validated);
96
+ }
97
+ return validated;
98
+ };
99
+ }
100
+ }
101
+ return client;
102
+ }
103
+
104
+ // src/create-executor.ts
105
+ function createExecutor(execute, middlewares = []) {
106
+ const chain = middlewares.reduceRight(
107
+ (next, mw) => (opts) => mw(opts, next),
108
+ execute
109
+ );
110
+ return { execute: chain };
111
+ }
112
+
113
+ // src/middleware.ts
114
+ function defineMiddleware(fn) {
115
+ return fn;
116
+ }
117
+ function withRetry(count, options) {
118
+ return defineMiddleware(async (opts, next) => {
119
+ let lastError;
120
+ for (let attempt = 0; attempt <= count; attempt++) {
121
+ try {
122
+ return await next(opts);
123
+ } catch (err) {
124
+ lastError = err;
125
+ if (attempt === count) break;
126
+ if (options?.shouldRetry && !options.shouldRetry(err, attempt)) break;
127
+ }
128
+ }
129
+ throw lastError;
130
+ });
131
+ }
132
+ function withTimeout(ms) {
133
+ return defineMiddleware(async (opts, next) => {
134
+ const controller = new AbortController();
135
+ const timer = setTimeout(() => controller.abort(), ms);
136
+ const signal = opts.signal ? anySignal([opts.signal, controller.signal]) : controller.signal;
137
+ try {
138
+ return await next({ ...opts, signal });
139
+ } finally {
140
+ clearTimeout(timer);
141
+ }
142
+ });
143
+ }
144
+ function withLogger(options) {
145
+ const log = options?.log ?? ((msg, data) => console.log(msg, data));
146
+ return defineMiddleware(async (opts, next) => {
147
+ const start = Date.now();
148
+ log(`[routar] ${opts.method} ${opts.url}`, { params: opts.params, body: opts.body });
149
+ try {
150
+ const result = await next(opts);
151
+ log(`[routar] ${opts.method} ${opts.url} \u2014 ${Date.now() - start}ms`);
152
+ return result;
153
+ } catch (err) {
154
+ log(`[routar] ${opts.method} ${opts.url} \u2014 error after ${Date.now() - start}ms`, err);
155
+ throw err;
156
+ }
157
+ });
158
+ }
159
+ function anySignal(signals) {
160
+ const controller = new AbortController();
161
+ for (const signal of signals) {
162
+ if (signal.aborted) {
163
+ controller.abort();
164
+ return controller.signal;
165
+ }
166
+ signal.addEventListener("abort", () => controller.abort(), { once: true });
167
+ }
168
+ return controller.signal;
169
+ }
170
+
171
+ // src/utils/params.ts
172
+ function serializeParams(params) {
173
+ const result = new URLSearchParams();
174
+ for (const [key, value] of Object.entries(params)) {
175
+ if (value == null) continue;
176
+ if (Array.isArray(value)) {
177
+ for (const item of value) {
178
+ if (item != null) result.append(key, String(item));
179
+ }
180
+ } else {
181
+ result.append(key, String(value));
182
+ }
183
+ }
184
+ return result;
185
+ }
186
+
187
+ exports.ValidationError = ValidationError;
188
+ exports.createApi = createApi;
189
+ exports.createExecutor = createExecutor;
190
+ exports.defineMiddleware = defineMiddleware;
191
+ exports.defineRouter = defineRouter;
192
+ exports.endpoint = endpoint;
193
+ exports.joinPaths = joinPaths;
194
+ exports.resolvePath = resolvePath;
195
+ exports.serializeParams = serializeParams;
196
+ exports.withLogger = withLogger;
197
+ exports.withRetry = withRetry;
198
+ exports.withTimeout = withTimeout;
199
+ //# sourceMappingURL=index.cjs.map
200
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/define-router.ts","../src/define-endpoint.ts","../src/utils/path.ts","../src/utils/validate.ts","../src/create-api.ts","../src/create-executor.ts","../src/middleware.ts","../src/utils/params.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["import type { RouterDef, RouterEndpoints } from './types.js';\n\n/**\n * Groups a set of endpoint specs (and optional nested routers) under a shared\n * URL prefix.\n *\n * The returned {@link RouterDef} can be passed directly to {@link createApi}\n * to produce a fully-typed API client. Nesting another {@link RouterDef} as a\n * value creates a sub-client whose prefix is the concatenation of both prefixes.\n *\n * @param prefix - Base path prepended to every endpoint's `path` (e.g. `'/users'`).\n * @param endpoints - Record of named {@link EndpointSpec}s or nested {@link RouterDef}s.\n *\n * @example\n * ```ts\n * // Flat router\n * export const todoRouter = defineRouter('/todos', {\n * getList: endpoint({ method: 'GET', path: '/', response: TodoListSchema }),\n * getDetail: endpoint({ method: 'GET', path: '/:id', response: TodoSchema }),\n * create: endpoint({ method: 'POST', path: '/', response: TodoSchema }),\n * });\n *\n * // Nested router — api.users.todos.getList() resolves to GET /users/todos/\n * export const userRouter = defineRouter('/users', {\n * getList: endpoint({ method: 'GET', path: '/', response: UserListSchema }),\n * todos: defineRouter('/todos', {\n * getList: endpoint({ method: 'GET', path: '/', response: TodoListSchema }),\n * getDetail: endpoint({ method: 'GET', path: '/:id', response: TodoSchema }),\n * }),\n * });\n * ```\n */\nexport function defineRouter<TEndpoints extends RouterEndpoints>(\n prefix: string,\n endpoints: TEndpoints,\n): RouterDef<TEndpoints> {\n return { prefix, endpoints };\n}\n","import type {\n HttpMethod,\n RequestShape,\n Validator,\n ValidatorOutput,\n} from './types.js';\n\n/**\n * Extracts `:param` segment names from a path template string as a union of\n * string literals.\n *\n * @example\n * ```ts\n * type P = PathParams<'/:userId/posts/:postId'>; // 'userId' | 'postId'\n * ```\n */\nexport type PathParams<TPath extends string> =\n TPath extends `${string}:${infer Param}/${infer Rest}`\n ? Param | PathParams<Rest>\n : TPath extends `${string}:${infer Param}`\n ? Param\n : never;\n\n/**\n * When `TPath` contains dynamic segments (`:param`), requires `request.path`\n * to include all extracted param names. No constraint for static paths.\n */\ntype PathConstraint<TPath extends string> =\n [PathParams<TPath>] extends [never]\n ? {}\n : { path: Record<PathParams<TPath>, unknown> };\n\n/**\n * Type-safe endpoint definition helper.\n *\n * Use this instead of a plain object literal to get full type inference on\n * `adapter` without requiring explicit annotations or `as any` casts.\n * The four overloads cover every combination of optional `request` validator\n * and optional `adapter` function while keeping all return-type fields\n * required so that TypeScript can narrow them downstream.\n *\n * When `path` contains dynamic segments (e.g. `'/:id'`), TypeScript enforces\n * that `request` includes a matching `path` field with those param names.\n * A mismatch or missing key is a compile-time error.\n *\n * @example\n * ```ts\n * // ✅ path has ':id' → request.path.id is required\n * const getDetail = endpoint({\n * method: 'GET',\n * path: '/:id',\n * request: z.object({ path: z.object({ id: z.number() }) }),\n * response: TodoSchema,\n * adapter: toTodoItem,\n * });\n *\n * // ❌ compile error — 'id' is missing from request.path\n * const broken = endpoint({\n * method: 'GET',\n * path: '/:id',\n * request: z.object({ query: z.object({ foo: z.string() }) }),\n * response: TodoSchema,\n * });\n * ```\n */\n// request O + adapter O\nexport function endpoint<\n TPath extends string,\n TRequest extends RequestShape & PathConstraint<TPath>,\n TResponse extends Validator<unknown>,\n TOut,\n>(spec: {\n method: HttpMethod;\n path: TPath;\n request: Validator<TRequest>;\n response: TResponse;\n adapter: (raw: ValidatorOutput<TResponse>) => TOut;\n}): {\n method: HttpMethod;\n path: string;\n request: Validator<TRequest>;\n response: TResponse;\n adapter: (raw: ValidatorOutput<TResponse>) => TOut;\n};\n\n// request O + adapter X\nexport function endpoint<\n TPath extends string,\n TRequest extends RequestShape & PathConstraint<TPath>,\n TResponse extends Validator<unknown>,\n>(spec: {\n method: HttpMethod;\n path: TPath;\n request: Validator<TRequest>;\n response: TResponse;\n}): {\n method: HttpMethod;\n path: string;\n request: Validator<TRequest>;\n response: TResponse;\n};\n\n// request X + adapter O\nexport function endpoint<\n TResponse extends Validator<unknown>,\n TOut,\n>(spec: {\n method: HttpMethod;\n path: string;\n response: TResponse;\n adapter: (raw: ValidatorOutput<TResponse>) => TOut;\n}): {\n method: HttpMethod;\n path: string;\n response: TResponse;\n adapter: (raw: ValidatorOutput<TResponse>) => TOut;\n};\n\n// request X + adapter X\nexport function endpoint<\n TResponse extends Validator<unknown>,\n>(spec: {\n method: HttpMethod;\n path: string;\n response: TResponse;\n}): {\n method: HttpMethod;\n path: string;\n response: TResponse;\n};\n\nexport function endpoint(spec: unknown): unknown {\n return spec;\n}\n","export function joinPaths(...segments: string[]): string {\n const joined = segments\n .filter(s => s !== '')\n .join('/')\n .replace(/\\/+/g, '/');\n return joined.endsWith('/') && joined.length > 1\n ? joined.slice(0, -1)\n : joined || '/';\n}\n\nexport function resolvePath(\n pathTemplate: string,\n params?: Record<string, unknown>,\n): string {\n if (!params) return pathTemplate;\n return pathTemplate.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, key) => {\n const value = params[key];\n if (value == null) throw new Error(`Missing path parameter: ${key}`);\n return encodeURIComponent(String(value));\n });\n}\n","export class ValidationError extends Error {\n constructor(\n message: string,\n public readonly cause?: unknown,\n ) {\n super(message);\n this.name = 'ValidationError';\n if (cause !== undefined) {\n Object.defineProperty(this, 'cause', {\n value: cause,\n writable: true,\n enumerable: true,\n });\n }\n }\n}\n","import type {\n Executor,\n RouterDef,\n RouterEndpoints,\n EndpointSpec,\n InferResponse,\n RequestShape,\n} from './types.js';\nimport { joinPaths, resolvePath } from './utils/path.js';\nimport { ValidationError } from './utils/validate.js';\n\n/** Callable type for a single endpoint on the generated API client. */\ntype EndpointFn<TSpec extends EndpointSpec<any, any, any>> = (\n params: TSpec['request'] extends { parse: (data: unknown) => infer R } ? R : RequestShape,\n signal?: AbortSignal,\n) => Promise<InferResponse<TSpec>>;\n\n/**\n * Fully-typed API client produced by {@link createApi}.\n * Nested {@link RouterDef} entries become nested sub-client objects.\n */\ntype ApiClient<TEndpoints extends RouterEndpoints> = {\n [K in keyof TEndpoints]: TEndpoints[K] extends RouterDef<infer TNestedEndpoints>\n ? ApiClient<TNestedEndpoints>\n : TEndpoints[K] extends EndpointSpec<any, any, any>\n ? EndpointFn<TEndpoints[K]>\n : never;\n};\n\n/**\n * Builds a fully-typed API client from an {@link Executor} and a router\n * (or bare endpoint map).\n *\n * Three call signatures are supported:\n * - `createApi(executor, router)` — preferred; pass the result of {@link defineRouter}.\n * - `createApi(executor, prefix, endpoints)` — inline router without {@link defineRouter}.\n * - `createApi(executor, endpoints)` — no prefix; useful for flat endpoint maps.\n *\n * Each key in `endpoints` becomes a typed async function on the returned client.\n * The function validates the request with `spec.request.parse` (if present),\n * resolves path parameters, calls the executor, validates the response with\n * `spec.response.parse`, and applies `spec.adapter` (if present).\n *\n * @param executor - Transport to use for every HTTP call.\n * @param router - A {@link RouterDef} produced by {@link defineRouter}.\n *\n * @example\n * ```ts\n * const todoApi = createApi(executor, todoRouter);\n * const todos = await todoApi.getList({});\n * const todo = await todoApi.getDetail({ path: { id: 1 } });\n * ```\n */\nexport function createApi<TEndpoints extends RouterEndpoints>(\n executor: Executor,\n router: RouterDef<TEndpoints>,\n): ApiClient<TEndpoints>;\n\n/**\n * @param executor - Transport to use for every HTTP call.\n * @param prefix - URL prefix prepended to every endpoint path.\n * @param endpoints - Record of named endpoint specs.\n */\nexport function createApi<TEndpoints extends RouterEndpoints>(\n executor: Executor,\n prefix: string,\n endpoints: TEndpoints,\n): ApiClient<TEndpoints>;\n\n/**\n * @param executor - Transport to use for every HTTP call.\n * @param endpoints - Record of named endpoint specs (no URL prefix).\n */\nexport function createApi<TEndpoints extends RouterEndpoints>(\n executor: Executor,\n endpoints: TEndpoints,\n): ApiClient<TEndpoints>;\n\nexport function createApi(\n executor: Executor,\n routerOrPrefixOrEndpoints: RouterDef<any> | RouterEndpoints | string,\n endpointsArg?: RouterEndpoints,\n): Record<string, any> {\n let prefix: string;\n let endpoints: RouterEndpoints;\n\n if (typeof routerOrPrefixOrEndpoints === 'string') {\n prefix = routerOrPrefixOrEndpoints;\n if (!endpointsArg) throw new Error('endpoints is required when prefix is provided');\n endpoints = endpointsArg;\n } else if (\n 'prefix' in routerOrPrefixOrEndpoints &&\n 'endpoints' in routerOrPrefixOrEndpoints\n ) {\n prefix = (routerOrPrefixOrEndpoints as RouterDef<any>).prefix;\n endpoints = (routerOrPrefixOrEndpoints as RouterDef<any>).endpoints;\n } else {\n prefix = '';\n endpoints = routerOrPrefixOrEndpoints as RouterEndpoints;\n }\n\n return buildClient(executor, prefix, endpoints);\n}\n\nfunction buildClient(\n executor: Executor,\n prefix: string,\n endpoints: RouterEndpoints,\n): Record<string, any> {\n const client: Record<string, any> = {};\n\n for (const [key, entry] of Object.entries(endpoints)) {\n if ('prefix' in entry && 'endpoints' in entry) {\n // Nested RouterDef — recurse with merged prefix\n const nested = entry as RouterDef<any>;\n client[key] = buildClient(executor, joinPaths(prefix, nested.prefix), nested.endpoints);\n } else {\n // Leaf EndpointSpec\n const spec = entry as EndpointSpec<any, any, any>;\n client[key] = async (params: RequestShape = {}, signal?: AbortSignal) => {\n let validatedParams: RequestShape = params;\n if (spec.request) {\n try {\n validatedParams = spec.request.parse(params);\n } catch (err) {\n throw new ValidationError('Request validation failed', err);\n }\n }\n\n const url = resolvePath(\n joinPaths(prefix, spec.path),\n validatedParams?.path,\n );\n\n const raw = await executor.execute({\n method: spec.method,\n url,\n params: validatedParams?.query as Record<string, unknown> | undefined,\n body: validatedParams?.body,\n signal,\n });\n\n let validated: unknown;\n try {\n validated = spec.response.parse(raw);\n } catch (err) {\n throw new ValidationError('Response validation failed', err);\n }\n\n if (spec.adapter) {\n return spec.adapter(validated as any);\n }\n return validated;\n };\n }\n }\n\n return client;\n}\n","import type { Executor, ExecuteOptions, ExecutorMiddleware } from './types.js';\n\n/**\n * Creates an {@link Executor} by wrapping a transport function with an\n * optional middleware chain.\n *\n * Middlewares are applied in declaration order — the first middleware is the\n * outermost wrapper and runs first on each request.\n *\n * @param execute - The underlying transport function (fetch, axios, etc.).\n * @param middlewares - Ordered list of middlewares to apply.\n *\n * @example\n * ```ts\n * const executor = createExecutor(\n * async ({ method, url, body }) => {\n * const res = await fetch(url, { method, body: JSON.stringify(body) });\n * return res.json();\n * },\n * [withTimeout(5000), withRetry(3), withLogger()],\n * );\n * ```\n */\nexport function createExecutor(\n execute: (options: ExecuteOptions) => Promise<unknown>,\n middlewares: ExecutorMiddleware[] = [],\n): Executor {\n const chain = middlewares.reduceRight<(options: ExecuteOptions) => Promise<unknown>>(\n (next, mw) => (opts) => mw(opts, next),\n execute,\n );\n return { execute: chain };\n}\n","import type { ExecutorMiddleware } from './types.js';\n\n/**\n * Identity helper that returns the middleware as-is.\n *\n * Wrap your middleware function with this to get full type inference on `opts`\n * and `next` without having to annotate the type manually.\n *\n * @example\n * ```ts\n * const withCorrelationId = defineMiddleware((opts, next) =>\n * next({ ...opts, headers: { ...opts.headers, 'X-Request-Id': crypto.randomUUID() } })\n * );\n * ```\n */\nexport function defineMiddleware(fn: ExecutorMiddleware): ExecutorMiddleware {\n return fn;\n}\n\n/**\n * Retries a failed request up to `count` additional times.\n *\n * By default all errors trigger a retry. Pass `shouldRetry` to skip retries\n * for non-transient errors (e.g. 4xx responses).\n *\n * @param count - Number of retries (not counting the initial attempt).\n * @param options.shouldRetry - Return `false` to stop retrying early.\n *\n * @example\n * ```ts\n * withRetry(3, {\n * shouldRetry: (err) => err instanceof HttpError && err.status >= 500,\n * })\n * ```\n */\nexport function withRetry(\n count: number,\n options?: { shouldRetry?: (error: unknown, attempt: number) => boolean },\n): ExecutorMiddleware {\n return defineMiddleware(async (opts, next) => {\n let lastError: unknown;\n for (let attempt = 0; attempt <= count; attempt++) {\n try {\n return await next(opts);\n } catch (err) {\n lastError = err;\n if (attempt === count) break;\n if (options?.shouldRetry && !options.shouldRetry(err, attempt)) break;\n }\n }\n throw lastError;\n });\n}\n\n/**\n * Aborts a request if it does not complete within `ms` milliseconds.\n *\n * Merges the timeout signal with any existing `AbortSignal` on the request,\n * so whichever fires first wins.\n *\n * @param ms - Timeout in milliseconds.\n */\nexport function withTimeout(ms: number): ExecutorMiddleware {\n return defineMiddleware(async (opts, next) => {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), ms);\n\n const signal = opts.signal\n ? anySignal([opts.signal, controller.signal])\n : controller.signal;\n\n try {\n return await next({ ...opts, signal });\n } finally {\n clearTimeout(timer);\n }\n });\n}\n\n/**\n * Logs each request and its outcome (success duration or error).\n *\n * @param options.log - Custom logging function. Defaults to `console.log`.\n *\n * @example\n * ```ts\n * withLogger({ log: (msg, data) => logger.debug(msg, data) })\n * ```\n */\nexport function withLogger(options?: {\n log?: (message: string, data?: unknown) => void;\n}): ExecutorMiddleware {\n const log = options?.log ?? ((msg, data) => console.log(msg, data));\n return defineMiddleware(async (opts, next) => {\n const start = Date.now();\n log(`[routar] ${opts.method} ${opts.url}`, { params: opts.params, body: opts.body });\n try {\n const result = await next(opts);\n log(`[routar] ${opts.method} ${opts.url} — ${Date.now() - start}ms`);\n return result;\n } catch (err) {\n log(`[routar] ${opts.method} ${opts.url} — error after ${Date.now() - start}ms`, err);\n throw err;\n }\n });\n}\n\n/** Combines multiple AbortSignals into one that aborts when any of them fire. */\nfunction anySignal(signals: AbortSignal[]): AbortSignal {\n const controller = new AbortController();\n for (const signal of signals) {\n if (signal.aborted) {\n controller.abort();\n return controller.signal;\n }\n signal.addEventListener('abort', () => controller.abort(), { once: true });\n }\n return controller.signal;\n}\n","export function serializeParams(params: Record<string, unknown>): URLSearchParams {\n const result = new URLSearchParams();\n for (const [key, value] of Object.entries(params)) {\n if (value == null) continue;\n if (Array.isArray(value)) {\n for (const item of value) {\n if (item != null) result.append(key, String(item));\n }\n } else {\n result.append(key, String(value));\n }\n }\n return result;\n}\n"]}
@@ -0,0 +1,382 @@
1
+ type HttpMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
2
+ /** Options passed to {@link Executor.execute} on every HTTP call. */
3
+ interface ExecuteOptions {
4
+ method: HttpMethod;
5
+ url: string;
6
+ params?: Record<string, unknown>;
7
+ body?: unknown;
8
+ headers?: Record<string, string>;
9
+ signal?: AbortSignal;
10
+ }
11
+ /**
12
+ * Transport abstraction. Implement this to support any HTTP client.
13
+ *
14
+ * @see {@link createExecutor} to build an executor with middleware support.
15
+ */
16
+ interface Executor {
17
+ execute(options: ExecuteOptions): Promise<unknown>;
18
+ }
19
+ /**
20
+ * Middleware function for an {@link Executor}.
21
+ *
22
+ * Receives the current {@link ExecuteOptions} and a `next` function to call
23
+ * the next middleware (or the underlying transport). Must return the response
24
+ * promise.
25
+ *
26
+ * @example
27
+ * ```ts
28
+ * const myMiddleware: ExecutorMiddleware = async (opts, next) => {
29
+ * console.log(opts.method, opts.url);
30
+ * return next(opts);
31
+ * };
32
+ * ```
33
+ */
34
+ type ExecutorMiddleware = (options: ExecuteOptions, next: (options: ExecuteOptions) => Promise<unknown>) => Promise<unknown>;
35
+ /**
36
+ * Any object with a `parse` method — compatible with Zod, Valibot, Yup, etc.
37
+ *
38
+ * @template TOutput Parsed output type.
39
+ */
40
+ interface Validator<TOutput> {
41
+ parse(data: unknown): TOutput;
42
+ }
43
+ /** Extracts the output type of a {@link Validator}. */
44
+ type ValidatorOutput<T extends Validator<unknown>> = T extends Validator<infer O> ? O : never;
45
+ /**
46
+ * Shape of an endpoint's request parameters.
47
+ *
48
+ * All fields are optional at the type level; each endpoint declares only what
49
+ * it actually uses via its `request` validator.
50
+ */
51
+ interface RequestShape {
52
+ path?: Record<string, unknown>;
53
+ query?: Record<string, unknown>;
54
+ body?: unknown;
55
+ }
56
+ /**
57
+ * Full specification of a single API endpoint.
58
+ *
59
+ * Prefer using the {@link endpoint} helper to define specs — it provides
60
+ * contextual typing for `adapter` without requiring `any` casts.
61
+ *
62
+ * @template TRequest Validated request shape.
63
+ * @template TResponse Validator for the raw response.
64
+ * @template TAdapter Optional adapter function type.
65
+ */
66
+ interface EndpointSpec<TRequest extends RequestShape = RequestShape, TResponse extends Validator<unknown> = Validator<unknown>, TAdapter extends ((raw: ValidatorOutput<TResponse>) => unknown) | undefined = undefined> {
67
+ method: HttpMethod;
68
+ path: string;
69
+ /** Validates and narrows request parameters before the HTTP call. */
70
+ request?: Validator<TRequest>;
71
+ /** Validates the raw server response. */
72
+ response: TResponse;
73
+ /**
74
+ * Transforms the validated response before returning to the caller.
75
+ * When present, the endpoint's return type is the adapter's output type.
76
+ */
77
+ adapter?: TAdapter;
78
+ }
79
+ /**
80
+ * Resolves the final return type of an endpoint call.
81
+ *
82
+ * - With `adapter`: returns the adapter's output type.
83
+ * - Without `adapter`: returns `ValidatorOutput<TResponse>`.
84
+ */
85
+ type InferResponse<TSpec extends EndpointSpec<any, any, any>> = TSpec['adapter'] extends (raw: any) => infer R ? R : ValidatorOutput<TSpec['response']>;
86
+ /**
87
+ * A single entry inside a {@link RouterEndpoints} map.
88
+ * Either a leaf endpoint spec or a nested {@link RouterDef}.
89
+ */
90
+ type RouterEntry = EndpointSpec<any, any, any> | RouterDef<any>;
91
+ /** A record of named {@link EndpointSpec}s or nested {@link RouterDef}s. */
92
+ type RouterEndpoints = Record<string, RouterEntry>;
93
+ /** The return type of {@link defineRouter}. Passed directly to {@link createApi}. */
94
+ interface RouterDef<TEndpoints extends RouterEndpoints = RouterEndpoints> {
95
+ prefix: string;
96
+ endpoints: TEndpoints;
97
+ }
98
+ /**
99
+ * Extracts request/response types from a typed API client for use in query
100
+ * hooks or mutation handlers.
101
+ *
102
+ * @example
103
+ * ```ts
104
+ * export type TodoApiTypes = ApiTypes<typeof todoApi>;
105
+ * type CreateRequest = TodoApiTypes['create']['request'];
106
+ * type CreateResponse = TodoApiTypes['create']['response'];
107
+ * ```
108
+ */
109
+ type ApiTypes<TApi> = {
110
+ [K in keyof TApi]: TApi[K] extends (...args: any[]) => Promise<infer R> ? {
111
+ request: Parameters<TApi[K]>[0];
112
+ response: R;
113
+ } : never;
114
+ };
115
+
116
+ /**
117
+ * Groups a set of endpoint specs (and optional nested routers) under a shared
118
+ * URL prefix.
119
+ *
120
+ * The returned {@link RouterDef} can be passed directly to {@link createApi}
121
+ * to produce a fully-typed API client. Nesting another {@link RouterDef} as a
122
+ * value creates a sub-client whose prefix is the concatenation of both prefixes.
123
+ *
124
+ * @param prefix - Base path prepended to every endpoint's `path` (e.g. `'/users'`).
125
+ * @param endpoints - Record of named {@link EndpointSpec}s or nested {@link RouterDef}s.
126
+ *
127
+ * @example
128
+ * ```ts
129
+ * // Flat router
130
+ * export const todoRouter = defineRouter('/todos', {
131
+ * getList: endpoint({ method: 'GET', path: '/', response: TodoListSchema }),
132
+ * getDetail: endpoint({ method: 'GET', path: '/:id', response: TodoSchema }),
133
+ * create: endpoint({ method: 'POST', path: '/', response: TodoSchema }),
134
+ * });
135
+ *
136
+ * // Nested router — api.users.todos.getList() resolves to GET /users/todos/
137
+ * export const userRouter = defineRouter('/users', {
138
+ * getList: endpoint({ method: 'GET', path: '/', response: UserListSchema }),
139
+ * todos: defineRouter('/todos', {
140
+ * getList: endpoint({ method: 'GET', path: '/', response: TodoListSchema }),
141
+ * getDetail: endpoint({ method: 'GET', path: '/:id', response: TodoSchema }),
142
+ * }),
143
+ * });
144
+ * ```
145
+ */
146
+ declare function defineRouter<TEndpoints extends RouterEndpoints>(prefix: string, endpoints: TEndpoints): RouterDef<TEndpoints>;
147
+
148
+ /**
149
+ * Extracts `:param` segment names from a path template string as a union of
150
+ * string literals.
151
+ *
152
+ * @example
153
+ * ```ts
154
+ * type P = PathParams<'/:userId/posts/:postId'>; // 'userId' | 'postId'
155
+ * ```
156
+ */
157
+ type PathParams<TPath extends string> = TPath extends `${string}:${infer Param}/${infer Rest}` ? Param | PathParams<Rest> : TPath extends `${string}:${infer Param}` ? Param : never;
158
+ /**
159
+ * When `TPath` contains dynamic segments (`:param`), requires `request.path`
160
+ * to include all extracted param names. No constraint for static paths.
161
+ */
162
+ type PathConstraint<TPath extends string> = [
163
+ PathParams<TPath>
164
+ ] extends [never] ? {} : {
165
+ path: Record<PathParams<TPath>, unknown>;
166
+ };
167
+ /**
168
+ * Type-safe endpoint definition helper.
169
+ *
170
+ * Use this instead of a plain object literal to get full type inference on
171
+ * `adapter` without requiring explicit annotations or `as any` casts.
172
+ * The four overloads cover every combination of optional `request` validator
173
+ * and optional `adapter` function while keeping all return-type fields
174
+ * required so that TypeScript can narrow them downstream.
175
+ *
176
+ * When `path` contains dynamic segments (e.g. `'/:id'`), TypeScript enforces
177
+ * that `request` includes a matching `path` field with those param names.
178
+ * A mismatch or missing key is a compile-time error.
179
+ *
180
+ * @example
181
+ * ```ts
182
+ * // ✅ path has ':id' → request.path.id is required
183
+ * const getDetail = endpoint({
184
+ * method: 'GET',
185
+ * path: '/:id',
186
+ * request: z.object({ path: z.object({ id: z.number() }) }),
187
+ * response: TodoSchema,
188
+ * adapter: toTodoItem,
189
+ * });
190
+ *
191
+ * // ❌ compile error — 'id' is missing from request.path
192
+ * const broken = endpoint({
193
+ * method: 'GET',
194
+ * path: '/:id',
195
+ * request: z.object({ query: z.object({ foo: z.string() }) }),
196
+ * response: TodoSchema,
197
+ * });
198
+ * ```
199
+ */
200
+ declare function endpoint<TPath extends string, TRequest extends RequestShape & PathConstraint<TPath>, TResponse extends Validator<unknown>, TOut>(spec: {
201
+ method: HttpMethod;
202
+ path: TPath;
203
+ request: Validator<TRequest>;
204
+ response: TResponse;
205
+ adapter: (raw: ValidatorOutput<TResponse>) => TOut;
206
+ }): {
207
+ method: HttpMethod;
208
+ path: string;
209
+ request: Validator<TRequest>;
210
+ response: TResponse;
211
+ adapter: (raw: ValidatorOutput<TResponse>) => TOut;
212
+ };
213
+ declare function endpoint<TPath extends string, TRequest extends RequestShape & PathConstraint<TPath>, TResponse extends Validator<unknown>>(spec: {
214
+ method: HttpMethod;
215
+ path: TPath;
216
+ request: Validator<TRequest>;
217
+ response: TResponse;
218
+ }): {
219
+ method: HttpMethod;
220
+ path: string;
221
+ request: Validator<TRequest>;
222
+ response: TResponse;
223
+ };
224
+ declare function endpoint<TResponse extends Validator<unknown>, TOut>(spec: {
225
+ method: HttpMethod;
226
+ path: string;
227
+ response: TResponse;
228
+ adapter: (raw: ValidatorOutput<TResponse>) => TOut;
229
+ }): {
230
+ method: HttpMethod;
231
+ path: string;
232
+ response: TResponse;
233
+ adapter: (raw: ValidatorOutput<TResponse>) => TOut;
234
+ };
235
+ declare function endpoint<TResponse extends Validator<unknown>>(spec: {
236
+ method: HttpMethod;
237
+ path: string;
238
+ response: TResponse;
239
+ }): {
240
+ method: HttpMethod;
241
+ path: string;
242
+ response: TResponse;
243
+ };
244
+
245
+ /** Callable type for a single endpoint on the generated API client. */
246
+ type EndpointFn<TSpec extends EndpointSpec<any, any, any>> = (params: TSpec['request'] extends {
247
+ parse: (data: unknown) => infer R;
248
+ } ? R : RequestShape, signal?: AbortSignal) => Promise<InferResponse<TSpec>>;
249
+ /**
250
+ * Fully-typed API client produced by {@link createApi}.
251
+ * Nested {@link RouterDef} entries become nested sub-client objects.
252
+ */
253
+ type ApiClient<TEndpoints extends RouterEndpoints> = {
254
+ [K in keyof TEndpoints]: TEndpoints[K] extends RouterDef<infer TNestedEndpoints> ? ApiClient<TNestedEndpoints> : TEndpoints[K] extends EndpointSpec<any, any, any> ? EndpointFn<TEndpoints[K]> : never;
255
+ };
256
+ /**
257
+ * Builds a fully-typed API client from an {@link Executor} and a router
258
+ * (or bare endpoint map).
259
+ *
260
+ * Three call signatures are supported:
261
+ * - `createApi(executor, router)` — preferred; pass the result of {@link defineRouter}.
262
+ * - `createApi(executor, prefix, endpoints)` — inline router without {@link defineRouter}.
263
+ * - `createApi(executor, endpoints)` — no prefix; useful for flat endpoint maps.
264
+ *
265
+ * Each key in `endpoints` becomes a typed async function on the returned client.
266
+ * The function validates the request with `spec.request.parse` (if present),
267
+ * resolves path parameters, calls the executor, validates the response with
268
+ * `spec.response.parse`, and applies `spec.adapter` (if present).
269
+ *
270
+ * @param executor - Transport to use for every HTTP call.
271
+ * @param router - A {@link RouterDef} produced by {@link defineRouter}.
272
+ *
273
+ * @example
274
+ * ```ts
275
+ * const todoApi = createApi(executor, todoRouter);
276
+ * const todos = await todoApi.getList({});
277
+ * const todo = await todoApi.getDetail({ path: { id: 1 } });
278
+ * ```
279
+ */
280
+ declare function createApi<TEndpoints extends RouterEndpoints>(executor: Executor, router: RouterDef<TEndpoints>): ApiClient<TEndpoints>;
281
+ /**
282
+ * @param executor - Transport to use for every HTTP call.
283
+ * @param prefix - URL prefix prepended to every endpoint path.
284
+ * @param endpoints - Record of named endpoint specs.
285
+ */
286
+ declare function createApi<TEndpoints extends RouterEndpoints>(executor: Executor, prefix: string, endpoints: TEndpoints): ApiClient<TEndpoints>;
287
+ /**
288
+ * @param executor - Transport to use for every HTTP call.
289
+ * @param endpoints - Record of named endpoint specs (no URL prefix).
290
+ */
291
+ declare function createApi<TEndpoints extends RouterEndpoints>(executor: Executor, endpoints: TEndpoints): ApiClient<TEndpoints>;
292
+
293
+ /**
294
+ * Creates an {@link Executor} by wrapping a transport function with an
295
+ * optional middleware chain.
296
+ *
297
+ * Middlewares are applied in declaration order — the first middleware is the
298
+ * outermost wrapper and runs first on each request.
299
+ *
300
+ * @param execute - The underlying transport function (fetch, axios, etc.).
301
+ * @param middlewares - Ordered list of middlewares to apply.
302
+ *
303
+ * @example
304
+ * ```ts
305
+ * const executor = createExecutor(
306
+ * async ({ method, url, body }) => {
307
+ * const res = await fetch(url, { method, body: JSON.stringify(body) });
308
+ * return res.json();
309
+ * },
310
+ * [withTimeout(5000), withRetry(3), withLogger()],
311
+ * );
312
+ * ```
313
+ */
314
+ declare function createExecutor(execute: (options: ExecuteOptions) => Promise<unknown>, middlewares?: ExecutorMiddleware[]): Executor;
315
+
316
+ /**
317
+ * Identity helper that returns the middleware as-is.
318
+ *
319
+ * Wrap your middleware function with this to get full type inference on `opts`
320
+ * and `next` without having to annotate the type manually.
321
+ *
322
+ * @example
323
+ * ```ts
324
+ * const withCorrelationId = defineMiddleware((opts, next) =>
325
+ * next({ ...opts, headers: { ...opts.headers, 'X-Request-Id': crypto.randomUUID() } })
326
+ * );
327
+ * ```
328
+ */
329
+ declare function defineMiddleware(fn: ExecutorMiddleware): ExecutorMiddleware;
330
+ /**
331
+ * Retries a failed request up to `count` additional times.
332
+ *
333
+ * By default all errors trigger a retry. Pass `shouldRetry` to skip retries
334
+ * for non-transient errors (e.g. 4xx responses).
335
+ *
336
+ * @param count - Number of retries (not counting the initial attempt).
337
+ * @param options.shouldRetry - Return `false` to stop retrying early.
338
+ *
339
+ * @example
340
+ * ```ts
341
+ * withRetry(3, {
342
+ * shouldRetry: (err) => err instanceof HttpError && err.status >= 500,
343
+ * })
344
+ * ```
345
+ */
346
+ declare function withRetry(count: number, options?: {
347
+ shouldRetry?: (error: unknown, attempt: number) => boolean;
348
+ }): ExecutorMiddleware;
349
+ /**
350
+ * Aborts a request if it does not complete within `ms` milliseconds.
351
+ *
352
+ * Merges the timeout signal with any existing `AbortSignal` on the request,
353
+ * so whichever fires first wins.
354
+ *
355
+ * @param ms - Timeout in milliseconds.
356
+ */
357
+ declare function withTimeout(ms: number): ExecutorMiddleware;
358
+ /**
359
+ * Logs each request and its outcome (success duration or error).
360
+ *
361
+ * @param options.log - Custom logging function. Defaults to `console.log`.
362
+ *
363
+ * @example
364
+ * ```ts
365
+ * withLogger({ log: (msg, data) => logger.debug(msg, data) })
366
+ * ```
367
+ */
368
+ declare function withLogger(options?: {
369
+ log?: (message: string, data?: unknown) => void;
370
+ }): ExecutorMiddleware;
371
+
372
+ declare function joinPaths(...segments: string[]): string;
373
+ declare function resolvePath(pathTemplate: string, params?: Record<string, unknown>): string;
374
+
375
+ declare function serializeParams(params: Record<string, unknown>): URLSearchParams;
376
+
377
+ declare class ValidationError extends Error {
378
+ readonly cause?: unknown | undefined;
379
+ constructor(message: string, cause?: unknown | undefined);
380
+ }
381
+
382
+ export { type ApiTypes, type EndpointSpec, type ExecuteOptions, type Executor, type ExecutorMiddleware, type HttpMethod, type InferResponse, type PathParams, type RequestShape, type RouterDef, type RouterEndpoints, type RouterEntry, ValidationError, type Validator, type ValidatorOutput, createApi, createExecutor, defineMiddleware, defineRouter, endpoint, joinPaths, resolvePath, serializeParams, withLogger, withRetry, withTimeout };
@@ -0,0 +1,382 @@
1
+ type HttpMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
2
+ /** Options passed to {@link Executor.execute} on every HTTP call. */
3
+ interface ExecuteOptions {
4
+ method: HttpMethod;
5
+ url: string;
6
+ params?: Record<string, unknown>;
7
+ body?: unknown;
8
+ headers?: Record<string, string>;
9
+ signal?: AbortSignal;
10
+ }
11
+ /**
12
+ * Transport abstraction. Implement this to support any HTTP client.
13
+ *
14
+ * @see {@link createExecutor} to build an executor with middleware support.
15
+ */
16
+ interface Executor {
17
+ execute(options: ExecuteOptions): Promise<unknown>;
18
+ }
19
+ /**
20
+ * Middleware function for an {@link Executor}.
21
+ *
22
+ * Receives the current {@link ExecuteOptions} and a `next` function to call
23
+ * the next middleware (or the underlying transport). Must return the response
24
+ * promise.
25
+ *
26
+ * @example
27
+ * ```ts
28
+ * const myMiddleware: ExecutorMiddleware = async (opts, next) => {
29
+ * console.log(opts.method, opts.url);
30
+ * return next(opts);
31
+ * };
32
+ * ```
33
+ */
34
+ type ExecutorMiddleware = (options: ExecuteOptions, next: (options: ExecuteOptions) => Promise<unknown>) => Promise<unknown>;
35
+ /**
36
+ * Any object with a `parse` method — compatible with Zod, Valibot, Yup, etc.
37
+ *
38
+ * @template TOutput Parsed output type.
39
+ */
40
+ interface Validator<TOutput> {
41
+ parse(data: unknown): TOutput;
42
+ }
43
+ /** Extracts the output type of a {@link Validator}. */
44
+ type ValidatorOutput<T extends Validator<unknown>> = T extends Validator<infer O> ? O : never;
45
+ /**
46
+ * Shape of an endpoint's request parameters.
47
+ *
48
+ * All fields are optional at the type level; each endpoint declares only what
49
+ * it actually uses via its `request` validator.
50
+ */
51
+ interface RequestShape {
52
+ path?: Record<string, unknown>;
53
+ query?: Record<string, unknown>;
54
+ body?: unknown;
55
+ }
56
+ /**
57
+ * Full specification of a single API endpoint.
58
+ *
59
+ * Prefer using the {@link endpoint} helper to define specs — it provides
60
+ * contextual typing for `adapter` without requiring `any` casts.
61
+ *
62
+ * @template TRequest Validated request shape.
63
+ * @template TResponse Validator for the raw response.
64
+ * @template TAdapter Optional adapter function type.
65
+ */
66
+ interface EndpointSpec<TRequest extends RequestShape = RequestShape, TResponse extends Validator<unknown> = Validator<unknown>, TAdapter extends ((raw: ValidatorOutput<TResponse>) => unknown) | undefined = undefined> {
67
+ method: HttpMethod;
68
+ path: string;
69
+ /** Validates and narrows request parameters before the HTTP call. */
70
+ request?: Validator<TRequest>;
71
+ /** Validates the raw server response. */
72
+ response: TResponse;
73
+ /**
74
+ * Transforms the validated response before returning to the caller.
75
+ * When present, the endpoint's return type is the adapter's output type.
76
+ */
77
+ adapter?: TAdapter;
78
+ }
79
+ /**
80
+ * Resolves the final return type of an endpoint call.
81
+ *
82
+ * - With `adapter`: returns the adapter's output type.
83
+ * - Without `adapter`: returns `ValidatorOutput<TResponse>`.
84
+ */
85
+ type InferResponse<TSpec extends EndpointSpec<any, any, any>> = TSpec['adapter'] extends (raw: any) => infer R ? R : ValidatorOutput<TSpec['response']>;
86
+ /**
87
+ * A single entry inside a {@link RouterEndpoints} map.
88
+ * Either a leaf endpoint spec or a nested {@link RouterDef}.
89
+ */
90
+ type RouterEntry = EndpointSpec<any, any, any> | RouterDef<any>;
91
+ /** A record of named {@link EndpointSpec}s or nested {@link RouterDef}s. */
92
+ type RouterEndpoints = Record<string, RouterEntry>;
93
+ /** The return type of {@link defineRouter}. Passed directly to {@link createApi}. */
94
+ interface RouterDef<TEndpoints extends RouterEndpoints = RouterEndpoints> {
95
+ prefix: string;
96
+ endpoints: TEndpoints;
97
+ }
98
+ /**
99
+ * Extracts request/response types from a typed API client for use in query
100
+ * hooks or mutation handlers.
101
+ *
102
+ * @example
103
+ * ```ts
104
+ * export type TodoApiTypes = ApiTypes<typeof todoApi>;
105
+ * type CreateRequest = TodoApiTypes['create']['request'];
106
+ * type CreateResponse = TodoApiTypes['create']['response'];
107
+ * ```
108
+ */
109
+ type ApiTypes<TApi> = {
110
+ [K in keyof TApi]: TApi[K] extends (...args: any[]) => Promise<infer R> ? {
111
+ request: Parameters<TApi[K]>[0];
112
+ response: R;
113
+ } : never;
114
+ };
115
+
116
+ /**
117
+ * Groups a set of endpoint specs (and optional nested routers) under a shared
118
+ * URL prefix.
119
+ *
120
+ * The returned {@link RouterDef} can be passed directly to {@link createApi}
121
+ * to produce a fully-typed API client. Nesting another {@link RouterDef} as a
122
+ * value creates a sub-client whose prefix is the concatenation of both prefixes.
123
+ *
124
+ * @param prefix - Base path prepended to every endpoint's `path` (e.g. `'/users'`).
125
+ * @param endpoints - Record of named {@link EndpointSpec}s or nested {@link RouterDef}s.
126
+ *
127
+ * @example
128
+ * ```ts
129
+ * // Flat router
130
+ * export const todoRouter = defineRouter('/todos', {
131
+ * getList: endpoint({ method: 'GET', path: '/', response: TodoListSchema }),
132
+ * getDetail: endpoint({ method: 'GET', path: '/:id', response: TodoSchema }),
133
+ * create: endpoint({ method: 'POST', path: '/', response: TodoSchema }),
134
+ * });
135
+ *
136
+ * // Nested router — api.users.todos.getList() resolves to GET /users/todos/
137
+ * export const userRouter = defineRouter('/users', {
138
+ * getList: endpoint({ method: 'GET', path: '/', response: UserListSchema }),
139
+ * todos: defineRouter('/todos', {
140
+ * getList: endpoint({ method: 'GET', path: '/', response: TodoListSchema }),
141
+ * getDetail: endpoint({ method: 'GET', path: '/:id', response: TodoSchema }),
142
+ * }),
143
+ * });
144
+ * ```
145
+ */
146
+ declare function defineRouter<TEndpoints extends RouterEndpoints>(prefix: string, endpoints: TEndpoints): RouterDef<TEndpoints>;
147
+
148
+ /**
149
+ * Extracts `:param` segment names from a path template string as a union of
150
+ * string literals.
151
+ *
152
+ * @example
153
+ * ```ts
154
+ * type P = PathParams<'/:userId/posts/:postId'>; // 'userId' | 'postId'
155
+ * ```
156
+ */
157
+ type PathParams<TPath extends string> = TPath extends `${string}:${infer Param}/${infer Rest}` ? Param | PathParams<Rest> : TPath extends `${string}:${infer Param}` ? Param : never;
158
+ /**
159
+ * When `TPath` contains dynamic segments (`:param`), requires `request.path`
160
+ * to include all extracted param names. No constraint for static paths.
161
+ */
162
+ type PathConstraint<TPath extends string> = [
163
+ PathParams<TPath>
164
+ ] extends [never] ? {} : {
165
+ path: Record<PathParams<TPath>, unknown>;
166
+ };
167
+ /**
168
+ * Type-safe endpoint definition helper.
169
+ *
170
+ * Use this instead of a plain object literal to get full type inference on
171
+ * `adapter` without requiring explicit annotations or `as any` casts.
172
+ * The four overloads cover every combination of optional `request` validator
173
+ * and optional `adapter` function while keeping all return-type fields
174
+ * required so that TypeScript can narrow them downstream.
175
+ *
176
+ * When `path` contains dynamic segments (e.g. `'/:id'`), TypeScript enforces
177
+ * that `request` includes a matching `path` field with those param names.
178
+ * A mismatch or missing key is a compile-time error.
179
+ *
180
+ * @example
181
+ * ```ts
182
+ * // ✅ path has ':id' → request.path.id is required
183
+ * const getDetail = endpoint({
184
+ * method: 'GET',
185
+ * path: '/:id',
186
+ * request: z.object({ path: z.object({ id: z.number() }) }),
187
+ * response: TodoSchema,
188
+ * adapter: toTodoItem,
189
+ * });
190
+ *
191
+ * // ❌ compile error — 'id' is missing from request.path
192
+ * const broken = endpoint({
193
+ * method: 'GET',
194
+ * path: '/:id',
195
+ * request: z.object({ query: z.object({ foo: z.string() }) }),
196
+ * response: TodoSchema,
197
+ * });
198
+ * ```
199
+ */
200
+ declare function endpoint<TPath extends string, TRequest extends RequestShape & PathConstraint<TPath>, TResponse extends Validator<unknown>, TOut>(spec: {
201
+ method: HttpMethod;
202
+ path: TPath;
203
+ request: Validator<TRequest>;
204
+ response: TResponse;
205
+ adapter: (raw: ValidatorOutput<TResponse>) => TOut;
206
+ }): {
207
+ method: HttpMethod;
208
+ path: string;
209
+ request: Validator<TRequest>;
210
+ response: TResponse;
211
+ adapter: (raw: ValidatorOutput<TResponse>) => TOut;
212
+ };
213
+ declare function endpoint<TPath extends string, TRequest extends RequestShape & PathConstraint<TPath>, TResponse extends Validator<unknown>>(spec: {
214
+ method: HttpMethod;
215
+ path: TPath;
216
+ request: Validator<TRequest>;
217
+ response: TResponse;
218
+ }): {
219
+ method: HttpMethod;
220
+ path: string;
221
+ request: Validator<TRequest>;
222
+ response: TResponse;
223
+ };
224
+ declare function endpoint<TResponse extends Validator<unknown>, TOut>(spec: {
225
+ method: HttpMethod;
226
+ path: string;
227
+ response: TResponse;
228
+ adapter: (raw: ValidatorOutput<TResponse>) => TOut;
229
+ }): {
230
+ method: HttpMethod;
231
+ path: string;
232
+ response: TResponse;
233
+ adapter: (raw: ValidatorOutput<TResponse>) => TOut;
234
+ };
235
+ declare function endpoint<TResponse extends Validator<unknown>>(spec: {
236
+ method: HttpMethod;
237
+ path: string;
238
+ response: TResponse;
239
+ }): {
240
+ method: HttpMethod;
241
+ path: string;
242
+ response: TResponse;
243
+ };
244
+
245
+ /** Callable type for a single endpoint on the generated API client. */
246
+ type EndpointFn<TSpec extends EndpointSpec<any, any, any>> = (params: TSpec['request'] extends {
247
+ parse: (data: unknown) => infer R;
248
+ } ? R : RequestShape, signal?: AbortSignal) => Promise<InferResponse<TSpec>>;
249
+ /**
250
+ * Fully-typed API client produced by {@link createApi}.
251
+ * Nested {@link RouterDef} entries become nested sub-client objects.
252
+ */
253
+ type ApiClient<TEndpoints extends RouterEndpoints> = {
254
+ [K in keyof TEndpoints]: TEndpoints[K] extends RouterDef<infer TNestedEndpoints> ? ApiClient<TNestedEndpoints> : TEndpoints[K] extends EndpointSpec<any, any, any> ? EndpointFn<TEndpoints[K]> : never;
255
+ };
256
+ /**
257
+ * Builds a fully-typed API client from an {@link Executor} and a router
258
+ * (or bare endpoint map).
259
+ *
260
+ * Three call signatures are supported:
261
+ * - `createApi(executor, router)` — preferred; pass the result of {@link defineRouter}.
262
+ * - `createApi(executor, prefix, endpoints)` — inline router without {@link defineRouter}.
263
+ * - `createApi(executor, endpoints)` — no prefix; useful for flat endpoint maps.
264
+ *
265
+ * Each key in `endpoints` becomes a typed async function on the returned client.
266
+ * The function validates the request with `spec.request.parse` (if present),
267
+ * resolves path parameters, calls the executor, validates the response with
268
+ * `spec.response.parse`, and applies `spec.adapter` (if present).
269
+ *
270
+ * @param executor - Transport to use for every HTTP call.
271
+ * @param router - A {@link RouterDef} produced by {@link defineRouter}.
272
+ *
273
+ * @example
274
+ * ```ts
275
+ * const todoApi = createApi(executor, todoRouter);
276
+ * const todos = await todoApi.getList({});
277
+ * const todo = await todoApi.getDetail({ path: { id: 1 } });
278
+ * ```
279
+ */
280
+ declare function createApi<TEndpoints extends RouterEndpoints>(executor: Executor, router: RouterDef<TEndpoints>): ApiClient<TEndpoints>;
281
+ /**
282
+ * @param executor - Transport to use for every HTTP call.
283
+ * @param prefix - URL prefix prepended to every endpoint path.
284
+ * @param endpoints - Record of named endpoint specs.
285
+ */
286
+ declare function createApi<TEndpoints extends RouterEndpoints>(executor: Executor, prefix: string, endpoints: TEndpoints): ApiClient<TEndpoints>;
287
+ /**
288
+ * @param executor - Transport to use for every HTTP call.
289
+ * @param endpoints - Record of named endpoint specs (no URL prefix).
290
+ */
291
+ declare function createApi<TEndpoints extends RouterEndpoints>(executor: Executor, endpoints: TEndpoints): ApiClient<TEndpoints>;
292
+
293
+ /**
294
+ * Creates an {@link Executor} by wrapping a transport function with an
295
+ * optional middleware chain.
296
+ *
297
+ * Middlewares are applied in declaration order — the first middleware is the
298
+ * outermost wrapper and runs first on each request.
299
+ *
300
+ * @param execute - The underlying transport function (fetch, axios, etc.).
301
+ * @param middlewares - Ordered list of middlewares to apply.
302
+ *
303
+ * @example
304
+ * ```ts
305
+ * const executor = createExecutor(
306
+ * async ({ method, url, body }) => {
307
+ * const res = await fetch(url, { method, body: JSON.stringify(body) });
308
+ * return res.json();
309
+ * },
310
+ * [withTimeout(5000), withRetry(3), withLogger()],
311
+ * );
312
+ * ```
313
+ */
314
+ declare function createExecutor(execute: (options: ExecuteOptions) => Promise<unknown>, middlewares?: ExecutorMiddleware[]): Executor;
315
+
316
+ /**
317
+ * Identity helper that returns the middleware as-is.
318
+ *
319
+ * Wrap your middleware function with this to get full type inference on `opts`
320
+ * and `next` without having to annotate the type manually.
321
+ *
322
+ * @example
323
+ * ```ts
324
+ * const withCorrelationId = defineMiddleware((opts, next) =>
325
+ * next({ ...opts, headers: { ...opts.headers, 'X-Request-Id': crypto.randomUUID() } })
326
+ * );
327
+ * ```
328
+ */
329
+ declare function defineMiddleware(fn: ExecutorMiddleware): ExecutorMiddleware;
330
+ /**
331
+ * Retries a failed request up to `count` additional times.
332
+ *
333
+ * By default all errors trigger a retry. Pass `shouldRetry` to skip retries
334
+ * for non-transient errors (e.g. 4xx responses).
335
+ *
336
+ * @param count - Number of retries (not counting the initial attempt).
337
+ * @param options.shouldRetry - Return `false` to stop retrying early.
338
+ *
339
+ * @example
340
+ * ```ts
341
+ * withRetry(3, {
342
+ * shouldRetry: (err) => err instanceof HttpError && err.status >= 500,
343
+ * })
344
+ * ```
345
+ */
346
+ declare function withRetry(count: number, options?: {
347
+ shouldRetry?: (error: unknown, attempt: number) => boolean;
348
+ }): ExecutorMiddleware;
349
+ /**
350
+ * Aborts a request if it does not complete within `ms` milliseconds.
351
+ *
352
+ * Merges the timeout signal with any existing `AbortSignal` on the request,
353
+ * so whichever fires first wins.
354
+ *
355
+ * @param ms - Timeout in milliseconds.
356
+ */
357
+ declare function withTimeout(ms: number): ExecutorMiddleware;
358
+ /**
359
+ * Logs each request and its outcome (success duration or error).
360
+ *
361
+ * @param options.log - Custom logging function. Defaults to `console.log`.
362
+ *
363
+ * @example
364
+ * ```ts
365
+ * withLogger({ log: (msg, data) => logger.debug(msg, data) })
366
+ * ```
367
+ */
368
+ declare function withLogger(options?: {
369
+ log?: (message: string, data?: unknown) => void;
370
+ }): ExecutorMiddleware;
371
+
372
+ declare function joinPaths(...segments: string[]): string;
373
+ declare function resolvePath(pathTemplate: string, params?: Record<string, unknown>): string;
374
+
375
+ declare function serializeParams(params: Record<string, unknown>): URLSearchParams;
376
+
377
+ declare class ValidationError extends Error {
378
+ readonly cause?: unknown | undefined;
379
+ constructor(message: string, cause?: unknown | undefined);
380
+ }
381
+
382
+ export { type ApiTypes, type EndpointSpec, type ExecuteOptions, type Executor, type ExecutorMiddleware, type HttpMethod, type InferResponse, type PathParams, type RequestShape, type RouterDef, type RouterEndpoints, type RouterEntry, ValidationError, type Validator, type ValidatorOutput, createApi, createExecutor, defineMiddleware, defineRouter, endpoint, joinPaths, resolvePath, serializeParams, withLogger, withRetry, withTimeout };
package/dist/index.js ADDED
@@ -0,0 +1,187 @@
1
+ // src/define-router.ts
2
+ function defineRouter(prefix, endpoints) {
3
+ return { prefix, endpoints };
4
+ }
5
+
6
+ // src/define-endpoint.ts
7
+ function endpoint(spec) {
8
+ return spec;
9
+ }
10
+
11
+ // src/utils/path.ts
12
+ function joinPaths(...segments) {
13
+ const joined = segments.filter((s) => s !== "").join("/").replace(/\/+/g, "/");
14
+ return joined.endsWith("/") && joined.length > 1 ? joined.slice(0, -1) : joined || "/";
15
+ }
16
+ function resolvePath(pathTemplate, params) {
17
+ if (!params) return pathTemplate;
18
+ return pathTemplate.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, key) => {
19
+ const value = params[key];
20
+ if (value == null) throw new Error(`Missing path parameter: ${key}`);
21
+ return encodeURIComponent(String(value));
22
+ });
23
+ }
24
+
25
+ // src/utils/validate.ts
26
+ var ValidationError = class extends Error {
27
+ constructor(message, cause) {
28
+ super(message);
29
+ this.cause = cause;
30
+ this.name = "ValidationError";
31
+ if (cause !== void 0) {
32
+ Object.defineProperty(this, "cause", {
33
+ value: cause,
34
+ writable: true,
35
+ enumerable: true
36
+ });
37
+ }
38
+ }
39
+ };
40
+
41
+ // src/create-api.ts
42
+ function createApi(executor, routerOrPrefixOrEndpoints, endpointsArg) {
43
+ let prefix;
44
+ let endpoints;
45
+ if (typeof routerOrPrefixOrEndpoints === "string") {
46
+ prefix = routerOrPrefixOrEndpoints;
47
+ if (!endpointsArg) throw new Error("endpoints is required when prefix is provided");
48
+ endpoints = endpointsArg;
49
+ } else if ("prefix" in routerOrPrefixOrEndpoints && "endpoints" in routerOrPrefixOrEndpoints) {
50
+ prefix = routerOrPrefixOrEndpoints.prefix;
51
+ endpoints = routerOrPrefixOrEndpoints.endpoints;
52
+ } else {
53
+ prefix = "";
54
+ endpoints = routerOrPrefixOrEndpoints;
55
+ }
56
+ return buildClient(executor, prefix, endpoints);
57
+ }
58
+ function buildClient(executor, prefix, endpoints) {
59
+ const client = {};
60
+ for (const [key, entry] of Object.entries(endpoints)) {
61
+ if ("prefix" in entry && "endpoints" in entry) {
62
+ const nested = entry;
63
+ client[key] = buildClient(executor, joinPaths(prefix, nested.prefix), nested.endpoints);
64
+ } else {
65
+ const spec = entry;
66
+ client[key] = async (params = {}, signal) => {
67
+ let validatedParams = params;
68
+ if (spec.request) {
69
+ try {
70
+ validatedParams = spec.request.parse(params);
71
+ } catch (err) {
72
+ throw new ValidationError("Request validation failed", err);
73
+ }
74
+ }
75
+ const url = resolvePath(
76
+ joinPaths(prefix, spec.path),
77
+ validatedParams?.path
78
+ );
79
+ const raw = await executor.execute({
80
+ method: spec.method,
81
+ url,
82
+ params: validatedParams?.query,
83
+ body: validatedParams?.body,
84
+ signal
85
+ });
86
+ let validated;
87
+ try {
88
+ validated = spec.response.parse(raw);
89
+ } catch (err) {
90
+ throw new ValidationError("Response validation failed", err);
91
+ }
92
+ if (spec.adapter) {
93
+ return spec.adapter(validated);
94
+ }
95
+ return validated;
96
+ };
97
+ }
98
+ }
99
+ return client;
100
+ }
101
+
102
+ // src/create-executor.ts
103
+ function createExecutor(execute, middlewares = []) {
104
+ const chain = middlewares.reduceRight(
105
+ (next, mw) => (opts) => mw(opts, next),
106
+ execute
107
+ );
108
+ return { execute: chain };
109
+ }
110
+
111
+ // src/middleware.ts
112
+ function defineMiddleware(fn) {
113
+ return fn;
114
+ }
115
+ function withRetry(count, options) {
116
+ return defineMiddleware(async (opts, next) => {
117
+ let lastError;
118
+ for (let attempt = 0; attempt <= count; attempt++) {
119
+ try {
120
+ return await next(opts);
121
+ } catch (err) {
122
+ lastError = err;
123
+ if (attempt === count) break;
124
+ if (options?.shouldRetry && !options.shouldRetry(err, attempt)) break;
125
+ }
126
+ }
127
+ throw lastError;
128
+ });
129
+ }
130
+ function withTimeout(ms) {
131
+ return defineMiddleware(async (opts, next) => {
132
+ const controller = new AbortController();
133
+ const timer = setTimeout(() => controller.abort(), ms);
134
+ const signal = opts.signal ? anySignal([opts.signal, controller.signal]) : controller.signal;
135
+ try {
136
+ return await next({ ...opts, signal });
137
+ } finally {
138
+ clearTimeout(timer);
139
+ }
140
+ });
141
+ }
142
+ function withLogger(options) {
143
+ const log = options?.log ?? ((msg, data) => console.log(msg, data));
144
+ return defineMiddleware(async (opts, next) => {
145
+ const start = Date.now();
146
+ log(`[routar] ${opts.method} ${opts.url}`, { params: opts.params, body: opts.body });
147
+ try {
148
+ const result = await next(opts);
149
+ log(`[routar] ${opts.method} ${opts.url} \u2014 ${Date.now() - start}ms`);
150
+ return result;
151
+ } catch (err) {
152
+ log(`[routar] ${opts.method} ${opts.url} \u2014 error after ${Date.now() - start}ms`, err);
153
+ throw err;
154
+ }
155
+ });
156
+ }
157
+ function anySignal(signals) {
158
+ const controller = new AbortController();
159
+ for (const signal of signals) {
160
+ if (signal.aborted) {
161
+ controller.abort();
162
+ return controller.signal;
163
+ }
164
+ signal.addEventListener("abort", () => controller.abort(), { once: true });
165
+ }
166
+ return controller.signal;
167
+ }
168
+
169
+ // src/utils/params.ts
170
+ function serializeParams(params) {
171
+ const result = new URLSearchParams();
172
+ for (const [key, value] of Object.entries(params)) {
173
+ if (value == null) continue;
174
+ if (Array.isArray(value)) {
175
+ for (const item of value) {
176
+ if (item != null) result.append(key, String(item));
177
+ }
178
+ } else {
179
+ result.append(key, String(value));
180
+ }
181
+ }
182
+ return result;
183
+ }
184
+
185
+ export { ValidationError, createApi, createExecutor, defineMiddleware, defineRouter, endpoint, joinPaths, resolvePath, serializeParams, withLogger, withRetry, withTimeout };
186
+ //# sourceMappingURL=index.js.map
187
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/define-router.ts","../src/define-endpoint.ts","../src/utils/path.ts","../src/utils/validate.ts","../src/create-api.ts","../src/create-executor.ts","../src/middleware.ts","../src/utils/params.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["import type { RouterDef, RouterEndpoints } from './types.js';\n\n/**\n * Groups a set of endpoint specs (and optional nested routers) under a shared\n * URL prefix.\n *\n * The returned {@link RouterDef} can be passed directly to {@link createApi}\n * to produce a fully-typed API client. Nesting another {@link RouterDef} as a\n * value creates a sub-client whose prefix is the concatenation of both prefixes.\n *\n * @param prefix - Base path prepended to every endpoint's `path` (e.g. `'/users'`).\n * @param endpoints - Record of named {@link EndpointSpec}s or nested {@link RouterDef}s.\n *\n * @example\n * ```ts\n * // Flat router\n * export const todoRouter = defineRouter('/todos', {\n * getList: endpoint({ method: 'GET', path: '/', response: TodoListSchema }),\n * getDetail: endpoint({ method: 'GET', path: '/:id', response: TodoSchema }),\n * create: endpoint({ method: 'POST', path: '/', response: TodoSchema }),\n * });\n *\n * // Nested router — api.users.todos.getList() resolves to GET /users/todos/\n * export const userRouter = defineRouter('/users', {\n * getList: endpoint({ method: 'GET', path: '/', response: UserListSchema }),\n * todos: defineRouter('/todos', {\n * getList: endpoint({ method: 'GET', path: '/', response: TodoListSchema }),\n * getDetail: endpoint({ method: 'GET', path: '/:id', response: TodoSchema }),\n * }),\n * });\n * ```\n */\nexport function defineRouter<TEndpoints extends RouterEndpoints>(\n prefix: string,\n endpoints: TEndpoints,\n): RouterDef<TEndpoints> {\n return { prefix, endpoints };\n}\n","import type {\n HttpMethod,\n RequestShape,\n Validator,\n ValidatorOutput,\n} from './types.js';\n\n/**\n * Extracts `:param` segment names from a path template string as a union of\n * string literals.\n *\n * @example\n * ```ts\n * type P = PathParams<'/:userId/posts/:postId'>; // 'userId' | 'postId'\n * ```\n */\nexport type PathParams<TPath extends string> =\n TPath extends `${string}:${infer Param}/${infer Rest}`\n ? Param | PathParams<Rest>\n : TPath extends `${string}:${infer Param}`\n ? Param\n : never;\n\n/**\n * When `TPath` contains dynamic segments (`:param`), requires `request.path`\n * to include all extracted param names. No constraint for static paths.\n */\ntype PathConstraint<TPath extends string> =\n [PathParams<TPath>] extends [never]\n ? {}\n : { path: Record<PathParams<TPath>, unknown> };\n\n/**\n * Type-safe endpoint definition helper.\n *\n * Use this instead of a plain object literal to get full type inference on\n * `adapter` without requiring explicit annotations or `as any` casts.\n * The four overloads cover every combination of optional `request` validator\n * and optional `adapter` function while keeping all return-type fields\n * required so that TypeScript can narrow them downstream.\n *\n * When `path` contains dynamic segments (e.g. `'/:id'`), TypeScript enforces\n * that `request` includes a matching `path` field with those param names.\n * A mismatch or missing key is a compile-time error.\n *\n * @example\n * ```ts\n * // ✅ path has ':id' → request.path.id is required\n * const getDetail = endpoint({\n * method: 'GET',\n * path: '/:id',\n * request: z.object({ path: z.object({ id: z.number() }) }),\n * response: TodoSchema,\n * adapter: toTodoItem,\n * });\n *\n * // ❌ compile error — 'id' is missing from request.path\n * const broken = endpoint({\n * method: 'GET',\n * path: '/:id',\n * request: z.object({ query: z.object({ foo: z.string() }) }),\n * response: TodoSchema,\n * });\n * ```\n */\n// request O + adapter O\nexport function endpoint<\n TPath extends string,\n TRequest extends RequestShape & PathConstraint<TPath>,\n TResponse extends Validator<unknown>,\n TOut,\n>(spec: {\n method: HttpMethod;\n path: TPath;\n request: Validator<TRequest>;\n response: TResponse;\n adapter: (raw: ValidatorOutput<TResponse>) => TOut;\n}): {\n method: HttpMethod;\n path: string;\n request: Validator<TRequest>;\n response: TResponse;\n adapter: (raw: ValidatorOutput<TResponse>) => TOut;\n};\n\n// request O + adapter X\nexport function endpoint<\n TPath extends string,\n TRequest extends RequestShape & PathConstraint<TPath>,\n TResponse extends Validator<unknown>,\n>(spec: {\n method: HttpMethod;\n path: TPath;\n request: Validator<TRequest>;\n response: TResponse;\n}): {\n method: HttpMethod;\n path: string;\n request: Validator<TRequest>;\n response: TResponse;\n};\n\n// request X + adapter O\nexport function endpoint<\n TResponse extends Validator<unknown>,\n TOut,\n>(spec: {\n method: HttpMethod;\n path: string;\n response: TResponse;\n adapter: (raw: ValidatorOutput<TResponse>) => TOut;\n}): {\n method: HttpMethod;\n path: string;\n response: TResponse;\n adapter: (raw: ValidatorOutput<TResponse>) => TOut;\n};\n\n// request X + adapter X\nexport function endpoint<\n TResponse extends Validator<unknown>,\n>(spec: {\n method: HttpMethod;\n path: string;\n response: TResponse;\n}): {\n method: HttpMethod;\n path: string;\n response: TResponse;\n};\n\nexport function endpoint(spec: unknown): unknown {\n return spec;\n}\n","export function joinPaths(...segments: string[]): string {\n const joined = segments\n .filter(s => s !== '')\n .join('/')\n .replace(/\\/+/g, '/');\n return joined.endsWith('/') && joined.length > 1\n ? joined.slice(0, -1)\n : joined || '/';\n}\n\nexport function resolvePath(\n pathTemplate: string,\n params?: Record<string, unknown>,\n): string {\n if (!params) return pathTemplate;\n return pathTemplate.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, key) => {\n const value = params[key];\n if (value == null) throw new Error(`Missing path parameter: ${key}`);\n return encodeURIComponent(String(value));\n });\n}\n","export class ValidationError extends Error {\n constructor(\n message: string,\n public readonly cause?: unknown,\n ) {\n super(message);\n this.name = 'ValidationError';\n if (cause !== undefined) {\n Object.defineProperty(this, 'cause', {\n value: cause,\n writable: true,\n enumerable: true,\n });\n }\n }\n}\n","import type {\n Executor,\n RouterDef,\n RouterEndpoints,\n EndpointSpec,\n InferResponse,\n RequestShape,\n} from './types.js';\nimport { joinPaths, resolvePath } from './utils/path.js';\nimport { ValidationError } from './utils/validate.js';\n\n/** Callable type for a single endpoint on the generated API client. */\ntype EndpointFn<TSpec extends EndpointSpec<any, any, any>> = (\n params: TSpec['request'] extends { parse: (data: unknown) => infer R } ? R : RequestShape,\n signal?: AbortSignal,\n) => Promise<InferResponse<TSpec>>;\n\n/**\n * Fully-typed API client produced by {@link createApi}.\n * Nested {@link RouterDef} entries become nested sub-client objects.\n */\ntype ApiClient<TEndpoints extends RouterEndpoints> = {\n [K in keyof TEndpoints]: TEndpoints[K] extends RouterDef<infer TNestedEndpoints>\n ? ApiClient<TNestedEndpoints>\n : TEndpoints[K] extends EndpointSpec<any, any, any>\n ? EndpointFn<TEndpoints[K]>\n : never;\n};\n\n/**\n * Builds a fully-typed API client from an {@link Executor} and a router\n * (or bare endpoint map).\n *\n * Three call signatures are supported:\n * - `createApi(executor, router)` — preferred; pass the result of {@link defineRouter}.\n * - `createApi(executor, prefix, endpoints)` — inline router without {@link defineRouter}.\n * - `createApi(executor, endpoints)` — no prefix; useful for flat endpoint maps.\n *\n * Each key in `endpoints` becomes a typed async function on the returned client.\n * The function validates the request with `spec.request.parse` (if present),\n * resolves path parameters, calls the executor, validates the response with\n * `spec.response.parse`, and applies `spec.adapter` (if present).\n *\n * @param executor - Transport to use for every HTTP call.\n * @param router - A {@link RouterDef} produced by {@link defineRouter}.\n *\n * @example\n * ```ts\n * const todoApi = createApi(executor, todoRouter);\n * const todos = await todoApi.getList({});\n * const todo = await todoApi.getDetail({ path: { id: 1 } });\n * ```\n */\nexport function createApi<TEndpoints extends RouterEndpoints>(\n executor: Executor,\n router: RouterDef<TEndpoints>,\n): ApiClient<TEndpoints>;\n\n/**\n * @param executor - Transport to use for every HTTP call.\n * @param prefix - URL prefix prepended to every endpoint path.\n * @param endpoints - Record of named endpoint specs.\n */\nexport function createApi<TEndpoints extends RouterEndpoints>(\n executor: Executor,\n prefix: string,\n endpoints: TEndpoints,\n): ApiClient<TEndpoints>;\n\n/**\n * @param executor - Transport to use for every HTTP call.\n * @param endpoints - Record of named endpoint specs (no URL prefix).\n */\nexport function createApi<TEndpoints extends RouterEndpoints>(\n executor: Executor,\n endpoints: TEndpoints,\n): ApiClient<TEndpoints>;\n\nexport function createApi(\n executor: Executor,\n routerOrPrefixOrEndpoints: RouterDef<any> | RouterEndpoints | string,\n endpointsArg?: RouterEndpoints,\n): Record<string, any> {\n let prefix: string;\n let endpoints: RouterEndpoints;\n\n if (typeof routerOrPrefixOrEndpoints === 'string') {\n prefix = routerOrPrefixOrEndpoints;\n if (!endpointsArg) throw new Error('endpoints is required when prefix is provided');\n endpoints = endpointsArg;\n } else if (\n 'prefix' in routerOrPrefixOrEndpoints &&\n 'endpoints' in routerOrPrefixOrEndpoints\n ) {\n prefix = (routerOrPrefixOrEndpoints as RouterDef<any>).prefix;\n endpoints = (routerOrPrefixOrEndpoints as RouterDef<any>).endpoints;\n } else {\n prefix = '';\n endpoints = routerOrPrefixOrEndpoints as RouterEndpoints;\n }\n\n return buildClient(executor, prefix, endpoints);\n}\n\nfunction buildClient(\n executor: Executor,\n prefix: string,\n endpoints: RouterEndpoints,\n): Record<string, any> {\n const client: Record<string, any> = {};\n\n for (const [key, entry] of Object.entries(endpoints)) {\n if ('prefix' in entry && 'endpoints' in entry) {\n // Nested RouterDef — recurse with merged prefix\n const nested = entry as RouterDef<any>;\n client[key] = buildClient(executor, joinPaths(prefix, nested.prefix), nested.endpoints);\n } else {\n // Leaf EndpointSpec\n const spec = entry as EndpointSpec<any, any, any>;\n client[key] = async (params: RequestShape = {}, signal?: AbortSignal) => {\n let validatedParams: RequestShape = params;\n if (spec.request) {\n try {\n validatedParams = spec.request.parse(params);\n } catch (err) {\n throw new ValidationError('Request validation failed', err);\n }\n }\n\n const url = resolvePath(\n joinPaths(prefix, spec.path),\n validatedParams?.path,\n );\n\n const raw = await executor.execute({\n method: spec.method,\n url,\n params: validatedParams?.query as Record<string, unknown> | undefined,\n body: validatedParams?.body,\n signal,\n });\n\n let validated: unknown;\n try {\n validated = spec.response.parse(raw);\n } catch (err) {\n throw new ValidationError('Response validation failed', err);\n }\n\n if (spec.adapter) {\n return spec.adapter(validated as any);\n }\n return validated;\n };\n }\n }\n\n return client;\n}\n","import type { Executor, ExecuteOptions, ExecutorMiddleware } from './types.js';\n\n/**\n * Creates an {@link Executor} by wrapping a transport function with an\n * optional middleware chain.\n *\n * Middlewares are applied in declaration order — the first middleware is the\n * outermost wrapper and runs first on each request.\n *\n * @param execute - The underlying transport function (fetch, axios, etc.).\n * @param middlewares - Ordered list of middlewares to apply.\n *\n * @example\n * ```ts\n * const executor = createExecutor(\n * async ({ method, url, body }) => {\n * const res = await fetch(url, { method, body: JSON.stringify(body) });\n * return res.json();\n * },\n * [withTimeout(5000), withRetry(3), withLogger()],\n * );\n * ```\n */\nexport function createExecutor(\n execute: (options: ExecuteOptions) => Promise<unknown>,\n middlewares: ExecutorMiddleware[] = [],\n): Executor {\n const chain = middlewares.reduceRight<(options: ExecuteOptions) => Promise<unknown>>(\n (next, mw) => (opts) => mw(opts, next),\n execute,\n );\n return { execute: chain };\n}\n","import type { ExecutorMiddleware } from './types.js';\n\n/**\n * Identity helper that returns the middleware as-is.\n *\n * Wrap your middleware function with this to get full type inference on `opts`\n * and `next` without having to annotate the type manually.\n *\n * @example\n * ```ts\n * const withCorrelationId = defineMiddleware((opts, next) =>\n * next({ ...opts, headers: { ...opts.headers, 'X-Request-Id': crypto.randomUUID() } })\n * );\n * ```\n */\nexport function defineMiddleware(fn: ExecutorMiddleware): ExecutorMiddleware {\n return fn;\n}\n\n/**\n * Retries a failed request up to `count` additional times.\n *\n * By default all errors trigger a retry. Pass `shouldRetry` to skip retries\n * for non-transient errors (e.g. 4xx responses).\n *\n * @param count - Number of retries (not counting the initial attempt).\n * @param options.shouldRetry - Return `false` to stop retrying early.\n *\n * @example\n * ```ts\n * withRetry(3, {\n * shouldRetry: (err) => err instanceof HttpError && err.status >= 500,\n * })\n * ```\n */\nexport function withRetry(\n count: number,\n options?: { shouldRetry?: (error: unknown, attempt: number) => boolean },\n): ExecutorMiddleware {\n return defineMiddleware(async (opts, next) => {\n let lastError: unknown;\n for (let attempt = 0; attempt <= count; attempt++) {\n try {\n return await next(opts);\n } catch (err) {\n lastError = err;\n if (attempt === count) break;\n if (options?.shouldRetry && !options.shouldRetry(err, attempt)) break;\n }\n }\n throw lastError;\n });\n}\n\n/**\n * Aborts a request if it does not complete within `ms` milliseconds.\n *\n * Merges the timeout signal with any existing `AbortSignal` on the request,\n * so whichever fires first wins.\n *\n * @param ms - Timeout in milliseconds.\n */\nexport function withTimeout(ms: number): ExecutorMiddleware {\n return defineMiddleware(async (opts, next) => {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), ms);\n\n const signal = opts.signal\n ? anySignal([opts.signal, controller.signal])\n : controller.signal;\n\n try {\n return await next({ ...opts, signal });\n } finally {\n clearTimeout(timer);\n }\n });\n}\n\n/**\n * Logs each request and its outcome (success duration or error).\n *\n * @param options.log - Custom logging function. Defaults to `console.log`.\n *\n * @example\n * ```ts\n * withLogger({ log: (msg, data) => logger.debug(msg, data) })\n * ```\n */\nexport function withLogger(options?: {\n log?: (message: string, data?: unknown) => void;\n}): ExecutorMiddleware {\n const log = options?.log ?? ((msg, data) => console.log(msg, data));\n return defineMiddleware(async (opts, next) => {\n const start = Date.now();\n log(`[routar] ${opts.method} ${opts.url}`, { params: opts.params, body: opts.body });\n try {\n const result = await next(opts);\n log(`[routar] ${opts.method} ${opts.url} — ${Date.now() - start}ms`);\n return result;\n } catch (err) {\n log(`[routar] ${opts.method} ${opts.url} — error after ${Date.now() - start}ms`, err);\n throw err;\n }\n });\n}\n\n/** Combines multiple AbortSignals into one that aborts when any of them fire. */\nfunction anySignal(signals: AbortSignal[]): AbortSignal {\n const controller = new AbortController();\n for (const signal of signals) {\n if (signal.aborted) {\n controller.abort();\n return controller.signal;\n }\n signal.addEventListener('abort', () => controller.abort(), { once: true });\n }\n return controller.signal;\n}\n","export function serializeParams(params: Record<string, unknown>): URLSearchParams {\n const result = new URLSearchParams();\n for (const [key, value] of Object.entries(params)) {\n if (value == null) continue;\n if (Array.isArray(value)) {\n for (const item of value) {\n if (item != null) result.append(key, String(item));\n }\n } else {\n result.append(key, String(value));\n }\n }\n return result;\n}\n"]}
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@routar/core",
3
+ "version": "0.1.0",
4
+ "description": "Schema-first HTTP API client — endpoint definitions, typed router, API client factory, and middleware system",
5
+ "keywords": ["api", "http", "typescript", "zod", "schema", "type-safe", "fetch", "axios", "middleware", "rest"],
6
+ "author": "Kyungbae Min",
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/minr2kb/routar.git",
11
+ "directory": "packages/core"
12
+ },
13
+ "homepage": "https://github.com/minr2kb/routar#readme",
14
+ "type": "module",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "import": "./dist/index.js",
19
+ "require": "./dist/index.cjs"
20
+ }
21
+ },
22
+ "main": "./dist/index.cjs",
23
+ "module": "./dist/index.js",
24
+ "types": "./dist/index.d.ts",
25
+ "files": ["dist", "README.md", "LICENSE"],
26
+ "scripts": {
27
+ "build": "tsup",
28
+ "dev": "tsup --watch"
29
+ },
30
+ "publishConfig": { "access": "public" }
31
+ }