@routar/core 0.1.0 → 1.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/dist/index.d.ts CHANGED
@@ -1,10 +1,15 @@
1
- type HttpMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
1
+ type HttpMethod = "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
2
2
  /** Options passed to {@link Executor.execute} on every HTTP call. */
3
3
  interface ExecuteOptions {
4
4
  method: HttpMethod;
5
5
  url: string;
6
6
  params?: Record<string, unknown>;
7
7
  body?: unknown;
8
+ /**
9
+ * Per-request headers injected by middleware (e.g. `defineMiddleware`).
10
+ * Headers cannot be set from `createApi` call sites directly — use middleware
11
+ * to add dynamic headers such as `Authorization` or `X-Request-Id`.
12
+ */
8
13
  headers?: Record<string, string>;
9
14
  signal?: AbortSignal;
10
15
  }
@@ -82,7 +87,7 @@ interface EndpointSpec<TRequest extends RequestShape = RequestShape, TResponse e
82
87
  * - With `adapter`: returns the adapter's output type.
83
88
  * - Without `adapter`: returns `ValidatorOutput<TResponse>`.
84
89
  */
85
- type InferResponse<TSpec extends EndpointSpec<any, any, any>> = TSpec['adapter'] extends (raw: any) => infer R ? R : ValidatorOutput<TSpec['response']>;
90
+ type InferResponse<TSpec extends EndpointSpec<any, any, any>> = TSpec["adapter"] extends (raw: any) => infer R ? R : ValidatorOutput<TSpec["response"]>;
86
91
  /**
87
92
  * A single entry inside a {@link RouterEndpoints} map.
88
93
  * Either a leaf endpoint spec or a nested {@link RouterDef}.
@@ -97,53 +102,156 @@ interface RouterDef<TEndpoints extends RouterEndpoints = RouterEndpoints> {
97
102
  }
98
103
  /**
99
104
  * Extracts request/response types from a typed API client for use in query
100
- * hooks or mutation handlers.
105
+ * hooks or mutation handlers. Supports nested router clients recursively.
101
106
  *
102
107
  * @example
103
108
  * ```ts
104
109
  * export type TodoApiTypes = ApiTypes<typeof todoApi>;
105
110
  * type CreateRequest = TodoApiTypes['create']['request'];
106
111
  * type CreateResponse = TodoApiTypes['create']['response'];
112
+ *
113
+ * // Nested router: api.users.todos.getList
114
+ * type NestedTypes = ApiTypes<typeof api>;
115
+ * type ListReq = NestedTypes['users']['todos']['getList']['request'];
107
116
  * ```
108
117
  */
109
118
  type ApiTypes<TApi> = {
110
119
  [K in keyof TApi]: TApi[K] extends (...args: any[]) => Promise<infer R> ? {
111
120
  request: Parameters<TApi[K]>[0];
112
121
  response: R;
113
- } : never;
122
+ } : TApi[K] extends object ? ApiTypes<TApi[K]> : never;
114
123
  };
124
+ /**
125
+ * Options for {@link createApi}.
126
+ *
127
+ * @example
128
+ * ```ts
129
+ * // Disable all validation in production
130
+ * createApi(executor, router, { validate: process.env.NODE_ENV !== 'production' });
131
+ *
132
+ * // Keep request validation (catch call-site bugs), skip response in prod
133
+ * createApi(executor, router, { validate: { request: true, response: false } });
134
+ * ```
135
+ */
136
+ interface CreateApiOptions {
137
+ /**
138
+ * Controls whether request and response schemas are run at call time.
139
+ *
140
+ * - `true` (default) — validate both request and response.
141
+ * - `false` — skip both; raw params and raw response pass through.
142
+ * - `{ request?, response? }` — enable/disable each independently.
143
+ */
144
+ validate?: boolean | {
145
+ request?: boolean;
146
+ response?: boolean;
147
+ };
148
+ }
115
149
 
150
+ /** Callable type for a single endpoint on the generated API client. */
151
+ type EndpointFn<TSpec extends EndpointSpec<any, any, any>> = TSpec["request"] extends {
152
+ parse: (data: unknown) => infer R;
153
+ } ? (params: R, signal?: AbortSignal) => Promise<InferResponse<TSpec>> : (params?: RequestShape, signal?: AbortSignal) => Promise<InferResponse<TSpec>>;
116
154
  /**
117
- * Groups a set of endpoint specs (and optional nested routers) under a shared
118
- * URL prefix.
155
+ * Fully-typed API client produced by {@link createApi}.
156
+ * Nested {@link RouterDef} entries become nested sub-client objects.
157
+ */
158
+ type ApiClient<TEndpoints extends RouterEndpoints> = {
159
+ [K in keyof TEndpoints]: TEndpoints[K] extends RouterDef<infer TNestedEndpoints> ? ApiClient<TNestedEndpoints> : TEndpoints[K] extends EndpointSpec<any, any, any> ? EndpointFn<TEndpoints[K]> : never;
160
+ };
161
+ /**
162
+ * Builds a fully-typed API client from an {@link Executor} and a router
163
+ * (or bare endpoint map).
119
164
  *
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.
165
+ * Three call signatures are supported:
166
+ * - `createApi(executor, router)` preferred; pass the result of {@link defineRouter}.
167
+ * - `createApi(executor, prefix, endpoints)` inline router without {@link defineRouter}.
168
+ * - `createApi(executor, endpoints)` — no prefix; useful for flat endpoint maps.
123
169
  *
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.
170
+ * Each key in `endpoints` becomes a typed async function on the returned client.
171
+ * The function validates the request with `spec.request.parse` (if present),
172
+ * resolves path parameters, calls the executor, validates the response with
173
+ * `spec.response.parse`, and applies `spec.adapter` (if present).
174
+ *
175
+ * @param executor - Transport to use for every HTTP call.
176
+ * @param router - A {@link RouterDef} produced by {@link defineRouter}.
177
+ * @param options - Optional settings (e.g. `validate` to skip schema parsing in production).
126
178
  *
127
179
  * @example
128
180
  * ```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
- * });
181
+ * const todoApi = createApi(executor, todoRouter);
182
+ * const todos = await todoApi.getList({});
183
+ * const todo = await todoApi.getDetail({ path: { id: 1 } });
135
184
  *
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
- * }),
185
+ * // Skip response validation in production
186
+ * const prodApi = createApi(executor, todoRouter, {
187
+ * validate: { request: true, response: process.env.NODE_ENV !== 'production' },
143
188
  * });
144
189
  * ```
145
190
  */
146
- declare function defineRouter<TEndpoints extends RouterEndpoints>(prefix: string, endpoints: TEndpoints): RouterDef<TEndpoints>;
191
+ declare function createApi<TEndpoints extends RouterEndpoints>(executor: Executor, router: RouterDef<TEndpoints>, options?: CreateApiOptions): ApiClient<TEndpoints>;
192
+ /**
193
+ * @param executor - Transport to use for every HTTP call.
194
+ * @param prefix - URL prefix prepended to every endpoint path.
195
+ * @param endpoints - Record of named endpoint specs.
196
+ * @param options - Optional settings.
197
+ */
198
+ declare function createApi<TEndpoints extends RouterEndpoints>(executor: Executor, prefix: string, endpoints: TEndpoints, options?: CreateApiOptions): ApiClient<TEndpoints>;
199
+ /**
200
+ * @param executor - Transport to use for every HTTP call.
201
+ * @param endpoints - Record of named endpoint specs (no URL prefix).
202
+ * @param options - Optional settings.
203
+ */
204
+ declare function createApi<TEndpoints extends RouterEndpoints>(executor: Executor, endpoints: TEndpoints, options?: CreateApiOptions): ApiClient<TEndpoints>;
205
+
206
+ /**
207
+ * Creates an {@link Executor} by wrapping a transport function with an
208
+ * optional middleware chain.
209
+ *
210
+ * Middlewares are applied in declaration order — the first middleware is the
211
+ * outermost wrapper and runs first on each request.
212
+ *
213
+ * @param execute - The underlying transport function (fetch, axios, etc.).
214
+ * @param middlewares - Ordered list of middlewares to apply.
215
+ *
216
+ * @example
217
+ * ```ts
218
+ * const executor = createExecutor(
219
+ * async ({ method, url, body }) => {
220
+ * const res = await fetch(url, { method, body: JSON.stringify(body) });
221
+ * return res.json();
222
+ * },
223
+ * [withTimeout(5000), withRetry(3), withLogger()],
224
+ * );
225
+ * ```
226
+ */
227
+ declare function createExecutor(execute: (options: ExecuteOptions) => Promise<unknown>, middlewares?: ExecutorMiddleware[]): Executor;
228
+ /**
229
+ * Creates an {@link Executor} that selects the underlying transport at
230
+ * request time based on the result of `resolver`.
231
+ *
232
+ * Use this to unify SSR and CSR behind a single API client — the resolver
233
+ * picks the right executor per request, so `createApi` is called once and
234
+ * works in both environments without duplicate `*ServerApi` instances.
235
+ *
236
+ * The resolver receives the full {@link ExecuteOptions} so it can branch on
237
+ * environment, URL prefix, auth context, or any runtime condition.
238
+ *
239
+ * @param resolver - Called on every request; returns the executor to delegate to.
240
+ *
241
+ * @example
242
+ * ```ts
243
+ * // SSR vs CSR — pick transport based on environment
244
+ * const apiExecutor = dispatchExecutor(() =>
245
+ * typeof window === 'undefined' ? serverExecutor : clientExecutor,
246
+ * );
247
+ *
248
+ * // Route by URL prefix — internal routes use a different transport
249
+ * const apiExecutor = dispatchExecutor((opts) =>
250
+ * opts.url.startsWith('/internal') ? internalExecutor : publicExecutor,
251
+ * );
252
+ * ```
253
+ */
254
+ declare function dispatchExecutor(resolver: (opts: ExecuteOptions) => Executor): Executor;
147
255
 
148
256
  /**
149
257
  * Extracts `:param` segment names from a path template string as a union of
@@ -159,9 +267,7 @@ type PathParams<TPath extends string> = TPath extends `${string}:${infer Param}/
159
267
  * When `TPath` contains dynamic segments (`:param`), requires `request.path`
160
268
  * to include all extracted param names. No constraint for static paths.
161
269
  */
162
- type PathConstraint<TPath extends string> = [
163
- PathParams<TPath>
164
- ] extends [never] ? {} : {
270
+ type PathConstraint<TPath extends string> = [PathParams<TPath>] extends [never] ? {} : {
165
271
  path: Record<PathParams<TPath>, unknown>;
166
272
  };
167
273
  /**
@@ -242,77 +348,16 @@ declare function endpoint<TResponse extends Validator<unknown>>(spec: {
242
348
  response: TResponse;
243
349
  };
244
350
 
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>;
351
+ declare function defineRouter<TEndpoints extends RouterEndpoints>(prefix: string, endpoints: TEndpoints): RouterDef<TEndpoints>;
292
352
 
293
353
  /**
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
- * ```
354
+ * Thrown by {@link withTimeout} when a request exceeds the configured duration.
355
+ * Distinguishable from a user-initiated {@link AbortSignal} cancellation.
313
356
  */
314
- declare function createExecutor(execute: (options: ExecuteOptions) => Promise<unknown>, middlewares?: ExecutorMiddleware[]): Executor;
315
-
357
+ declare class TimeoutError extends Error {
358
+ readonly ms: number;
359
+ constructor(ms: number);
360
+ }
316
361
  /**
317
362
  * Identity helper that returns the middleware as-is.
318
363
  *
@@ -335,6 +380,8 @@ declare function defineMiddleware(fn: ExecutorMiddleware): ExecutorMiddleware;
335
380
  *
336
381
  * @param count - Number of retries (not counting the initial attempt).
337
382
  * @param options.shouldRetry - Return `false` to stop retrying early.
383
+ * Receives the error and a zero-based `attempt` index (0 = first failure,
384
+ * 1 = second failure, …) so you can limit retries by count or error type.
338
385
  *
339
386
  * @example
340
387
  * ```ts
@@ -369,14 +416,21 @@ declare function withLogger(options?: {
369
416
  log?: (message: string, data?: unknown) => void;
370
417
  }): ExecutorMiddleware;
371
418
 
419
+ declare function serializeParams(params: Record<string, unknown>): URLSearchParams;
420
+
421
+ /**
422
+ * Joins URL path segments, normalising repeated slashes and trailing slashes.
423
+ *
424
+ * **Note:** Intended for relative API paths only. Absolute URLs containing
425
+ * `://` will be collapsed (`https://` → `https:/`). Pass absolute URLs
426
+ * directly to the executor instead of through this helper.
427
+ */
372
428
  declare function joinPaths(...segments: string[]): string;
373
429
  declare function resolvePath(pathTemplate: string, params?: Record<string, unknown>): string;
374
430
 
375
- declare function serializeParams(params: Record<string, unknown>): URLSearchParams;
376
-
377
431
  declare class ValidationError extends Error {
378
- readonly cause?: unknown | undefined;
379
- constructor(message: string, cause?: unknown | undefined);
432
+ readonly cause?: unknown;
433
+ constructor(message: string, cause?: unknown);
380
434
  }
381
435
 
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 };
436
+ export { type ApiTypes, type CreateApiOptions, type EndpointSpec, type ExecuteOptions, type Executor, type ExecutorMiddleware, type HttpMethod, type InferResponse, type PathParams, type RequestShape, type RouterDef, type RouterEndpoints, type RouterEntry, TimeoutError, ValidationError, type Validator, type ValidatorOutput, createApi, createExecutor, defineMiddleware, defineRouter, dispatchExecutor, endpoint, joinPaths, resolvePath, serializeParams, withLogger, withRetry, withTimeout };
package/dist/index.js CHANGED
@@ -1,13 +1,11 @@
1
1
  // src/define-router.ts
2
+ function isRouterDef(entry) {
3
+ return "prefix" in entry && "endpoints" in entry;
4
+ }
2
5
  function defineRouter(prefix, endpoints) {
3
6
  return { prefix, endpoints };
4
7
  }
5
8
 
6
- // src/define-endpoint.ts
7
- function endpoint(spec) {
8
- return spec;
9
- }
10
-
11
9
  // src/utils/path.ts
12
10
  function joinPaths(...segments) {
13
11
  const joined = segments.filter((s) => s !== "").join("/").replace(/\/+/g, "/");
@@ -17,7 +15,7 @@ function resolvePath(pathTemplate, params) {
17
15
  if (!params) return pathTemplate;
18
16
  return pathTemplate.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, key) => {
19
17
  const value = params[key];
20
- if (value == null) throw new Error(`Missing path parameter: ${key}`);
18
+ if (value == null || value === "") throw new Error(`Missing path parameter: ${key}`);
21
19
  return encodeURIComponent(String(value));
22
20
  });
23
21
  }
@@ -26,89 +24,119 @@ function resolvePath(pathTemplate, params) {
26
24
  var ValidationError = class extends Error {
27
25
  constructor(message, cause) {
28
26
  super(message);
29
- this.cause = cause;
30
27
  this.name = "ValidationError";
31
28
  if (cause !== void 0) {
32
29
  Object.defineProperty(this, "cause", {
33
30
  value: cause,
34
- writable: true,
35
- enumerable: true
31
+ writable: false,
32
+ enumerable: false,
33
+ configurable: true
36
34
  });
37
35
  }
38
36
  }
39
37
  };
40
38
 
41
39
  // 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;
40
+ function createApi(executor, routerOrPrefixOrEndpoints, endpointsArgOrOptions, optionsArg) {
41
+ const { prefix, endpoints, options } = resolveArgs(
42
+ routerOrPrefixOrEndpoints,
43
+ endpointsArgOrOptions,
44
+ optionsArg
45
+ );
46
+ return buildClient(executor, prefix, endpoints, options);
47
+ }
48
+ function resolveArgs(second, third, fourth) {
49
+ if (typeof second === "string") {
50
+ if (!third)
51
+ throw new Error("endpoints is required when prefix is provided");
52
+ return {
53
+ prefix: second,
54
+ endpoints: third,
55
+ options: fourth
56
+ };
55
57
  }
56
- return buildClient(executor, prefix, endpoints);
58
+ if (isRouterDef(second)) {
59
+ return {
60
+ prefix: second.prefix,
61
+ endpoints: second.endpoints,
62
+ options: third
63
+ };
64
+ }
65
+ return {
66
+ prefix: "",
67
+ endpoints: second,
68
+ options: third
69
+ };
70
+ }
71
+ function shouldValidate(options, kind) {
72
+ const v = options?.validate;
73
+ if (v === void 0 || v === true) return true;
74
+ if (v === false) return false;
75
+ return v[kind] ?? true;
57
76
  }
58
- function buildClient(executor, prefix, endpoints) {
77
+ function buildClient(executor, prefix, endpoints, options) {
59
78
  const client = {};
60
79
  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
- }
80
+ client[key] = isRouterDef(entry) ? buildClient(executor, joinPaths(prefix, entry.prefix), entry.endpoints, options) : buildEndpointFn(executor, prefix, entry, options);
98
81
  }
99
82
  return client;
100
83
  }
84
+ function buildEndpointFn(executor, prefix, spec, options) {
85
+ return async (params = {}, signal) => {
86
+ let validatedParams = params;
87
+ if (spec.request && shouldValidate(options, "request")) {
88
+ try {
89
+ validatedParams = spec.request.parse(params);
90
+ } catch (err) {
91
+ throw new ValidationError("Request validation failed", err);
92
+ }
93
+ }
94
+ const url = resolvePath(joinPaths(prefix, spec.path), validatedParams?.path);
95
+ const raw = await executor.execute({
96
+ method: spec.method,
97
+ url,
98
+ params: validatedParams?.query,
99
+ body: validatedParams?.body,
100
+ signal
101
+ });
102
+ let result;
103
+ if (shouldValidate(options, "response")) {
104
+ try {
105
+ result = spec.response.parse(raw);
106
+ } catch (err) {
107
+ throw new ValidationError("Response validation failed", err);
108
+ }
109
+ } else {
110
+ result = raw;
111
+ }
112
+ return spec.adapter ? spec.adapter(result) : result;
113
+ };
114
+ }
101
115
 
102
116
  // src/create-executor.ts
103
117
  function createExecutor(execute, middlewares = []) {
104
- const chain = middlewares.reduceRight(
105
- (next, mw) => (opts) => mw(opts, next),
106
- execute
107
- );
118
+ const chain = middlewares.reduceRight((next, mw) => (opts) => mw(opts, next), execute);
108
119
  return { execute: chain };
109
120
  }
121
+ function dispatchExecutor(resolver) {
122
+ return {
123
+ execute: (opts) => resolver(opts).execute(opts)
124
+ };
125
+ }
126
+
127
+ // src/define-endpoint.ts
128
+ function endpoint(spec) {
129
+ return spec;
130
+ }
110
131
 
111
132
  // src/middleware.ts
133
+ var TimeoutError = class extends Error {
134
+ constructor(ms) {
135
+ super(`Request timed out after ${ms}ms`);
136
+ this.ms = ms;
137
+ this.name = "TimeoutError";
138
+ }
139
+ };
112
140
  function defineMiddleware(fn) {
113
141
  return fn;
114
142
  }
@@ -130,12 +158,14 @@ function withRetry(count, options) {
130
158
  function withTimeout(ms) {
131
159
  return defineMiddleware(async (opts, next) => {
132
160
  const controller = new AbortController();
133
- const timer = setTimeout(() => controller.abort(), ms);
134
- const signal = opts.signal ? anySignal([opts.signal, controller.signal]) : controller.signal;
161
+ const timer = setTimeout(() => controller.abort(new TimeoutError(ms)), ms);
162
+ const { signal, cleanup } = opts.signal ? anySignal([opts.signal, controller.signal]) : { signal: controller.signal, cleanup: () => {
163
+ } };
135
164
  try {
136
165
  return await next({ ...opts, signal });
137
166
  } finally {
138
167
  clearTimeout(timer);
168
+ cleanup();
139
169
  }
140
170
  });
141
171
  }
@@ -143,27 +173,41 @@ function withLogger(options) {
143
173
  const log = options?.log ?? ((msg, data) => console.log(msg, data));
144
174
  return defineMiddleware(async (opts, next) => {
145
175
  const start = Date.now();
146
- log(`[routar] ${opts.method} ${opts.url}`, { params: opts.params, body: opts.body });
176
+ log(`[routar] ${opts.method} ${opts.url}`, {
177
+ params: opts.params,
178
+ body: opts.body
179
+ });
147
180
  try {
148
181
  const result = await next(opts);
149
182
  log(`[routar] ${opts.method} ${opts.url} \u2014 ${Date.now() - start}ms`);
150
183
  return result;
151
184
  } catch (err) {
152
- log(`[routar] ${opts.method} ${opts.url} \u2014 error after ${Date.now() - start}ms`, err);
185
+ log(
186
+ `[routar] ${opts.method} ${opts.url} \u2014 error after ${Date.now() - start}ms`,
187
+ err
188
+ );
153
189
  throw err;
154
190
  }
155
191
  });
156
192
  }
157
193
  function anySignal(signals) {
158
194
  const controller = new AbortController();
159
- for (const signal of signals) {
160
- if (signal.aborted) {
195
+ const onAbort = () => controller.abort();
196
+ const attached = [];
197
+ for (const s of signals) {
198
+ if (s.aborted) {
161
199
  controller.abort();
162
- return controller.signal;
200
+ break;
163
201
  }
164
- signal.addEventListener("abort", () => controller.abort(), { once: true });
202
+ s.addEventListener("abort", onAbort, { once: true });
203
+ attached.push(s);
165
204
  }
166
- return controller.signal;
205
+ return {
206
+ signal: controller.signal,
207
+ cleanup: () => attached.forEach((s) => {
208
+ s.removeEventListener("abort", onAbort);
209
+ })
210
+ };
167
211
  }
168
212
 
169
213
  // src/utils/params.ts
@@ -175,6 +219,10 @@ function serializeParams(params) {
175
219
  for (const item of value) {
176
220
  if (item != null) result.append(key, String(item));
177
221
  }
222
+ } else if (typeof value === "object") {
223
+ throw new TypeError(
224
+ `serializeParams: value for key "${key}" is a plain object. Serialize it to a string before passing as a query parameter.`
225
+ );
178
226
  } else {
179
227
  result.append(key, String(value));
180
228
  }
@@ -182,6 +230,6 @@ function serializeParams(params) {
182
230
  return result;
183
231
  }
184
232
 
185
- export { ValidationError, createApi, createExecutor, defineMiddleware, defineRouter, endpoint, joinPaths, resolvePath, serializeParams, withLogger, withRetry, withTimeout };
233
+ export { TimeoutError, ValidationError, createApi, createExecutor, defineMiddleware, defineRouter, dispatchExecutor, endpoint, joinPaths, resolvePath, serializeParams, withLogger, withRetry, withTimeout };
186
234
  //# sourceMappingURL=index.js.map
187
235
  //# sourceMappingURL=index.js.map