@routar/core 1.2.0 → 1.3.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
@@ -6,7 +6,7 @@ interface ExecuteOptions {
6
6
  params?: Record<string, unknown>;
7
7
  body?: unknown;
8
8
  /**
9
- * Per-request headers injected by middleware (e.g. `defineMiddleware`).
9
+ * Per-request headers injected by a plugin's `onRequest` hook.
10
10
  * Headers cannot be set from `createApi` call sites directly — use middleware
11
11
  * to add dynamic headers such as `Authorization` or `X-Request-Id`.
12
12
  */
@@ -22,21 +22,51 @@ interface Executor {
22
22
  execute(options: ExecuteOptions): Promise<unknown>;
23
23
  }
24
24
  /**
25
- * Middleware function for an {@link Executor}.
25
+ * A named, composable unit of executor behavior.
26
26
  *
27
- * Receives the current {@link ExecuteOptions} and a `next` function to call
28
- * the next middleware (or the underlying transport). Must return the response
29
- * promise.
27
+ * Each lifecycle hook is optional implement only what you need.
28
+ * Related concerns (e.g. auth header injection + 401 refresh) can be
29
+ * bundled into a single plugin.
30
30
  *
31
- * @example
31
+ * @example Auth plugin
32
32
  * ```ts
33
- * const myMiddleware: ExecutorMiddleware = async (opts, next) => {
34
- * console.log(opts.method, opts.url);
35
- * return next(opts);
33
+ * const authPlugin: ExecutorPlugin = {
34
+ * name: 'auth',
35
+ * onRequest: async (opts) => ({
36
+ * ...opts,
37
+ * headers: { ...opts.headers, Authorization: `Bearer ${await getToken()}` },
38
+ * }),
39
+ * onError: async (err) => {
40
+ * if (isUnauthorized(err)) await refreshToken();
41
+ * throw err;
42
+ * },
36
43
  * };
37
44
  * ```
38
45
  */
39
- type ExecutorMiddleware = (options: ExecuteOptions, next: (options: ExecuteOptions) => Promise<unknown>) => Promise<unknown>;
46
+ interface ExecutorPlugin {
47
+ /** Optional name — used for introspection and `eject`. */
48
+ name?: string;
49
+ /** Runs before the request is sent. Return modified opts to transform the request. */
50
+ onRequest?: (opts: ExecuteOptions) => ExecuteOptions | Promise<ExecuteOptions>;
51
+ /** Runs after a successful response. Return a modified value to transform the response. */
52
+ onResponse?: (response: unknown, opts: ExecuteOptions) => unknown | Promise<unknown>;
53
+ /** Runs when the request throws. Must re-throw (or throw a different error). */
54
+ onError?: (error: unknown, opts: ExecuteOptions) => never | Promise<never>;
55
+ }
56
+ /**
57
+ * Options for {@link createExecutor}.
58
+ *
59
+ * @example
60
+ * ```ts
61
+ * const executor = createExecutor(transport, {
62
+ * plugins: [authPlugin, logger()],
63
+ * });
64
+ * ```
65
+ */
66
+ interface CreateExecutorOptions {
67
+ /** Plugins applied in declaration order (first plugin is outermost). */
68
+ plugins?: ExecutorPlugin[];
69
+ }
40
70
  /**
41
71
  * Any object with a `parse` method — compatible with Zod, Valibot, Yup, etc.
42
72
  *
@@ -176,13 +206,38 @@ type ApiClient<TEndpoints extends RouterEndpoints> = {
176
206
  * @param router - A {@link RouterDef} produced by {@link defineRouter}.
177
207
  * @param options - Optional settings (e.g. `validate` to skip schema parsing in production).
178
208
  *
179
- * @example
209
+ * @example Basic usage
180
210
  * ```ts
181
211
  * const todoApi = createApi(executor, todoRouter);
182
212
  * const todos = await todoApi.getList({});
183
213
  * const todo = await todoApi.getDetail({ path: { id: 1 } });
214
+ * const next = await todoApi.create({ body: { title: 'buy milk' } });
215
+ * ```
216
+ *
217
+ * @example Nested router — access via dot notation
218
+ * ```ts
219
+ * const api = createApi(executor, apiRouter); // apiRouter has users → todos nesting
220
+ * await api.users.getList({});
221
+ * await api.users.todos.getList({});
222
+ * ```
223
+ *
224
+ * @example Cancel in-flight requests with AbortSignal
225
+ * ```ts
226
+ * const controller = new AbortController();
227
+ * const todos = await todoApi.getList({}, controller.signal);
228
+ * controller.abort();
229
+ * ```
230
+ *
231
+ * @example Extract types from the client — no duplication
232
+ * ```ts
233
+ * import type { ApiTypes } from '@routar/core';
234
+ * type TodoApiTypes = ApiTypes<typeof todoApi>;
235
+ * type Todo = TodoApiTypes['getDetail']['response'];
236
+ * type CreateRequest = TodoApiTypes['create']['request'];
237
+ * ```
184
238
  *
185
- * // Skip response validation in production
239
+ * @example Skip response validation in production
240
+ * ```ts
186
241
  * const prodApi = createApi(executor, todoRouter, {
187
242
  * validate: { request: true, response: process.env.NODE_ENV !== 'production' },
188
243
  * });
@@ -204,55 +259,43 @@ declare function createApi<TEndpoints extends RouterEndpoints>(executor: Executo
204
259
  declare function createApi<TEndpoints extends RouterEndpoints>(executor: Executor, endpoints: TEndpoints, options?: CreateApiOptions): ApiClient<TEndpoints>;
205
260
 
206
261
  /**
207
- * Creates an {@link Executor} by wrapping a transport function with an
208
- * optional middleware chain.
262
+ * Creates an {@link Executor} by wrapping a transport function with plugins.
209
263
  *
210
- * Middlewares are applied in declaration order — the first middleware is the
211
- * outermost wrapper and runs first on each request.
264
+ * Plugins run in declaration order (first plugin is outermost).
212
265
  *
213
- * @param execute - The underlying transport function (fetch, axios, etc.).
214
- * @param middlewares - Ordered list of middlewares to apply.
266
+ * For `retry` and `timeout`, use the options on {@link createFetchExecutor}
267
+ * or configure them via the underlying HTTP client (axios, ky).
215
268
  *
216
269
  * @example
217
270
  * ```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
- * );
271
+ * const executor = createExecutor(transport, {
272
+ * plugins: [authPlugin, logger()],
273
+ * });
225
274
  * ```
226
275
  */
227
- declare function createExecutor(execute: (options: ExecuteOptions) => Promise<unknown>, middlewares?: ExecutorMiddleware[]): Executor;
276
+ declare function createExecutor(execute: (options: ExecuteOptions) => Promise<unknown>, options?: CreateExecutorOptions): Executor;
228
277
  /**
229
278
  * Creates an {@link Executor} that selects the underlying transport at
230
279
  * request time based on the result of `resolver`.
231
280
  *
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
281
  * @example
242
282
  * ```ts
243
- * // SSR vs CSR — pick transport based on environment
244
283
  * const apiExecutor = dispatchExecutor(() =>
245
284
  * typeof window === 'undefined' ? serverExecutor : clientExecutor,
246
285
  * );
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
286
  * ```
253
287
  */
254
288
  declare function dispatchExecutor(resolver: (opts: ExecuteOptions) => Executor): Executor;
255
289
 
290
+ type FetchRetryOption = number | {
291
+ count: number;
292
+ shouldRetry?: (error: unknown, attempt: number) => boolean;
293
+ };
294
+ interface FetchExecutorOptions extends CreateExecutorOptions {
295
+ defaultHeaders?: () => Record<string, string> | Promise<Record<string, string>>;
296
+ retry?: FetchRetryOption;
297
+ timeout?: number;
298
+ }
256
299
  /**
257
300
  * Creates an {@link Executor} backed by the browser / Node.js `fetch` API.
258
301
  *
@@ -268,9 +311,20 @@ declare function dispatchExecutor(resolver: (opts: ExecuteOptions) => Executor):
268
311
  * @param baseURL - Absolute base URL prepended to every endpoint path.
269
312
  * @param options.defaultHeaders - Async factory called on every request to
270
313
  * produce headers (e.g. reading cookies in a Next.js server component).
271
- * @param options.middlewares - Middleware chain applied before the fetch call.
314
+ * @param options.plugins - Plugins applied around the fetch call. Each retry
315
+ * attempt re-runs `onRequest` hooks (so headers are refreshed per attempt)
316
+ * and `onError` hooks. Token-refresh-on-401 patterns work by updating an
317
+ * external token store in `onError` and letting `onRequest` pick it up on
318
+ * the next attempt.
319
+ * @param options.retry - Number of retries, or `{ count, shouldRetry? }`.
320
+ * @param options.timeout - Per-attempt timeout in milliseconds.
321
+ *
322
+ * @example Minimal — no options needed
323
+ * ```ts
324
+ * const executor = createFetchExecutor('https://api.example.com');
325
+ * ```
272
326
  *
273
- * @example
327
+ * @example SSR with bearer token
274
328
  * ```ts
275
329
  * const executor = createFetchExecutor('https://api.example.com', {
276
330
  * defaultHeaders: async () => {
@@ -279,11 +333,16 @@ declare function dispatchExecutor(resolver: (opts: ExecuteOptions) => Executor):
279
333
  * },
280
334
  * });
281
335
  * ```
336
+ *
337
+ * @example With retry and timeout
338
+ * ```ts
339
+ * const executor = createFetchExecutor('https://api.example.com', {
340
+ * retry: 2,
341
+ * timeout: 8_000,
342
+ * });
343
+ * ```
282
344
  */
283
- declare function createFetchExecutor(baseURL: string, options?: {
284
- defaultHeaders?: () => Record<string, string> | Promise<Record<string, string>>;
285
- middlewares?: ExecutorMiddleware[];
286
- }): Executor;
345
+ declare function createFetchExecutor(baseURL: string, options?: FetchExecutorOptions): Executor;
287
346
  /**
288
347
  * Thrown by {@link createFetchExecutor} when the server returns a non-2xx
289
348
  * status code.
@@ -300,19 +359,10 @@ declare function createFetchExecutor(baseURL: string, options?: {
300
359
  * ```
301
360
  */
302
361
  declare class HttpError extends Error {
303
- /** HTTP status code (e.g. 404, 500). */
304
362
  readonly status: number;
305
- /** HTTP status text (e.g. "Not Found"). */
306
363
  readonly statusText: string;
307
- /** Parsed response body, or `null` if the body was empty or not JSON. */
308
364
  readonly body: unknown;
309
- constructor(
310
- /** HTTP status code (e.g. 404, 500). */
311
- status: number,
312
- /** HTTP status text (e.g. "Not Found"). */
313
- statusText: string,
314
- /** Parsed response body, or `null` if the body was empty or not JSON. */
315
- body?: unknown);
365
+ constructor(status: number, statusText: string, body?: unknown);
316
366
  }
317
367
 
318
368
  /**
@@ -345,7 +395,43 @@ type PathConstraint<TPath extends string> = [PathParams<TPath>] extends [never]
345
395
  * that `request` includes a matching `path` field with those param names.
346
396
  * A mismatch or missing key is a compile-time error.
347
397
  *
348
- * @example
398
+ * @example Basic GET with no params
399
+ * ```ts
400
+ * const getList = endpoint({ method: 'GET', path: '/', response: z.array(TodoSchema) });
401
+ * ```
402
+ *
403
+ * @example GET with query params
404
+ * ```ts
405
+ * const search = endpoint({
406
+ * method: 'GET',
407
+ * path: '/search',
408
+ * request: z.object({ query: z.object({ q: z.string(), limit: z.number().optional() }) }),
409
+ * response: z.array(TodoSchema),
410
+ * });
411
+ * ```
412
+ *
413
+ * @example POST with body
414
+ * ```ts
415
+ * const create = endpoint({
416
+ * method: 'POST',
417
+ * path: '/',
418
+ * request: z.object({ body: z.object({ title: z.string() }) }),
419
+ * response: TodoSchema,
420
+ * });
421
+ * ```
422
+ *
423
+ * @example Adapter — raw is inferred from the response schema, no cast needed
424
+ * ```ts
425
+ * const getDetail = endpoint({
426
+ * method: 'GET',
427
+ * path: '/:id',
428
+ * request: z.object({ path: z.object({ id: z.number() }) }),
429
+ * response: TodoRawSchema,
430
+ * adapter: (raw) => ({ ...raw, label: `#${raw.id} ${raw.title}` }),
431
+ * });
432
+ * ```
433
+ *
434
+ * @example Path param enforcement
349
435
  * ```ts
350
436
  * // ✅ path has ':id' → request.path.id is required
351
437
  * const getDetail = endpoint({
@@ -353,7 +439,6 @@ type PathConstraint<TPath extends string> = [PathParams<TPath>] extends [never]
353
439
  * path: '/:id',
354
440
  * request: z.object({ path: z.object({ id: z.number() }) }),
355
441
  * response: TodoSchema,
356
- * adapter: toTodoItem,
357
442
  * });
358
443
  *
359
444
  * // ❌ compile error — 'id' is missing from request.path
@@ -445,57 +530,29 @@ declare function isRouterDef(entry: object): entry is RouterDef<RouterEndpoints>
445
530
  declare function defineRouter<TEndpoints extends RouterEndpoints>(prefix: string, endpoints: TEndpoints): RouterDef<TEndpoints>;
446
531
 
447
532
  /**
448
- * Thrown by {@link withTimeout} when a request exceeds the configured duration.
449
- * Distinguishable from a user-initiated {@link AbortSignal} cancellation.
533
+ * Thrown by the built-in `timeout` option when a request exceeds the
534
+ * configured duration. Distinguishable from a user-initiated
535
+ * {@link AbortSignal} cancellation.
450
536
  */
451
537
  declare class TimeoutError extends Error {
452
538
  readonly ms: number;
453
539
  constructor(ms: number);
454
540
  }
455
541
  /**
456
- * Identity helper that returns the middleware as-is.
457
- *
458
- * Wrap your middleware function with this to get full type inference on `opts`
459
- * and `next` without having to annotate the type manually.
542
+ * Identity helper that returns the plugin as-is, providing full type inference.
460
543
  *
461
544
  * @example
462
545
  * ```ts
463
- * const withCorrelationId = defineMiddleware((opts, next) =>
464
- * next({ ...opts, headers: { ...opts.headers, 'X-Request-Id': crypto.randomUUID() } })
465
- * );
466
- * ```
467
- */
468
- declare function defineMiddleware(fn: ExecutorMiddleware): ExecutorMiddleware;
469
- /**
470
- * Retries a failed request up to `count` additional times.
471
- *
472
- * By default all errors trigger a retry. Pass `shouldRetry` to skip retries
473
- * for non-transient errors (e.g. 4xx responses).
474
- *
475
- * @param count - Number of retries (not counting the initial attempt).
476
- * @param options.shouldRetry - Return `false` to stop retrying early.
477
- * Receives the error and a zero-based `attempt` index (0 = first failure,
478
- * 1 = second failure, …) so you can limit retries by count or error type.
479
- *
480
- * @example
481
- * ```ts
482
- * withRetry(3, {
483
- * shouldRetry: (err) => err instanceof HttpError && err.status >= 500,
484
- * })
546
+ * const authPlugin = definePlugin({
547
+ * name: 'auth',
548
+ * onRequest: async (opts) => ({
549
+ * ...opts,
550
+ * headers: { ...opts.headers, Authorization: `Bearer ${await getToken()}` },
551
+ * }),
552
+ * });
485
553
  * ```
486
554
  */
487
- declare function withRetry(count: number, options?: {
488
- shouldRetry?: (error: unknown, attempt: number) => boolean;
489
- }): ExecutorMiddleware;
490
- /**
491
- * Aborts a request if it does not complete within `ms` milliseconds.
492
- *
493
- * Merges the timeout signal with any existing `AbortSignal` on the request,
494
- * so whichever fires first wins.
495
- *
496
- * @param ms - Timeout in milliseconds.
497
- */
498
- declare function withTimeout(ms: number): ExecutorMiddleware;
555
+ declare function definePlugin(plugin: ExecutorPlugin): ExecutorPlugin;
499
556
  /**
500
557
  * Logs each request and its outcome (success duration or error).
501
558
  *
@@ -503,12 +560,14 @@ declare function withTimeout(ms: number): ExecutorMiddleware;
503
560
  *
504
561
  * @example
505
562
  * ```ts
506
- * withLogger({ log: (msg, data) => logger.debug(msg, data) })
563
+ * createExecutor(transport, {
564
+ * plugins: [logger({ log: (msg, data) => myLogger.debug(msg, data) })],
565
+ * })
507
566
  * ```
508
567
  */
509
- declare function withLogger(options?: {
568
+ declare function logger(options?: {
510
569
  log?: (message: string, data?: unknown) => void;
511
- }): ExecutorMiddleware;
570
+ }): ExecutorPlugin;
512
571
 
513
572
  declare function serializeParams(params: Record<string, unknown>): URLSearchParams;
514
573
 
@@ -527,4 +586,4 @@ declare class ValidationError extends Error {
527
586
  constructor(message: string, cause?: unknown);
528
587
  }
529
588
 
530
- export { type ApiTypes, type CreateApiOptions, type EndpointSpec, type ExecuteOptions, type Executor, type ExecutorMiddleware, HttpError, type HttpMethod, type InferResponse, type PathParams, type RequestShape, type RouterDef, type RouterEndpoints, type RouterEntry, TimeoutError, ValidationError, type Validator, type ValidatorOutput, createApi, createExecutor, createFetchExecutor, defineMiddleware, defineRouter, dispatchExecutor, endpoint, isRouterDef, joinPaths, resolvePath, serializeParams, withLogger, withRetry, withTimeout };
589
+ export { type ApiTypes, type CreateApiOptions, type CreateExecutorOptions, type EndpointSpec, type ExecuteOptions, type Executor, type ExecutorPlugin, type FetchExecutorOptions, type FetchRetryOption, HttpError, type HttpMethod, type InferResponse, type PathParams, type RequestShape, type RouterDef, type RouterEndpoints, type RouterEntry, TimeoutError, ValidationError, type Validator, type ValidatorOutput, createApi, createExecutor, createFetchExecutor, definePlugin, defineRouter, dispatchExecutor, endpoint, isRouterDef, joinPaths, logger, resolvePath, serializeParams };
package/dist/index.js CHANGED
@@ -114,83 +114,35 @@ function buildEndpointFn(executor, prefix, spec, options) {
114
114
  }
115
115
 
116
116
  // src/create-executor.ts
117
- function createExecutor(execute, middlewares = []) {
118
- const chain = middlewares.reduceRight((next, mw) => (opts) => mw(opts, next), execute);
119
- return { execute: chain };
120
- }
121
- function dispatchExecutor(resolver) {
122
- return {
123
- execute: (opts) => resolver(opts).execute(opts)
124
- };
125
- }
126
-
127
- // src/utils/params.ts
128
- function serializeParams(params) {
129
- const result = new URLSearchParams();
130
- for (const [key, value] of Object.entries(params)) {
131
- if (value == null) continue;
132
- if (Array.isArray(value)) {
133
- for (const item of value) {
134
- if (item != null) result.append(key, String(item));
117
+ function pluginToMiddleware(plugin) {
118
+ return async (opts, next) => {
119
+ const resolvedOpts = plugin.onRequest ? await plugin.onRequest(opts) : opts;
120
+ try {
121
+ const response = await next(resolvedOpts);
122
+ return plugin.onResponse ? await plugin.onResponse(response, resolvedOpts) : response;
123
+ } catch (err) {
124
+ if (plugin.onError) {
125
+ await plugin.onError(err, resolvedOpts);
126
+ throw err;
135
127
  }
136
- } else if (typeof value === "object") {
137
- throw new TypeError(
138
- `serializeParams: value for key "${key}" is a plain object. Serialize it to a string before passing as a query parameter.`
139
- );
140
- } else {
141
- result.append(key, String(value));
128
+ throw err;
142
129
  }
143
- }
144
- return result;
130
+ };
145
131
  }
146
-
147
- // src/create-fetch-executor.ts
148
- function createFetchExecutor(baseURL, options) {
149
- return createExecutor(
150
- async ({ method, url, params, body, headers, signal }) => {
151
- const fullURL = new URL(baseURL.replace(/\/$/, "") + url);
152
- if (params) {
153
- serializeParams(params).forEach((v, k) => {
154
- fullURL.searchParams.set(k, v);
155
- });
156
- }
157
- const defaultHeaders = await options?.defaultHeaders?.() ?? {};
158
- const res = await fetch(fullURL.toString(), {
159
- method,
160
- headers: {
161
- ...defaultHeaders,
162
- ...headers,
163
- ...body != null ? { "Content-Type": "application/json" } : {}
164
- },
165
- body: body != null ? JSON.stringify(body) : void 0,
166
- signal
167
- });
168
- if (!res.ok) {
169
- const errorBody = await res.json().catch(() => null);
170
- throw new HttpError(res.status, res.statusText, errorBody);
171
- }
172
- if (res.status === 204 || res.status === 205 || res.status === 304) {
173
- return null;
174
- }
175
- const text = await res.text();
176
- return text === "" ? null : JSON.parse(text);
177
- },
178
- options?.middlewares
132
+ function buildChain(execute, middlewares) {
133
+ return middlewares.reduceRight(
134
+ (next, mw) => (opts) => mw(opts, next),
135
+ execute
179
136
  );
180
137
  }
181
- var HttpError = class extends Error {
182
- constructor(status, statusText, body = null) {
183
- super(`HTTP ${status}: ${statusText}`);
184
- this.status = status;
185
- this.statusText = statusText;
186
- this.body = body;
187
- this.name = "HttpError";
188
- }
189
- };
190
-
191
- // src/define-endpoint.ts
192
- function endpoint(spec) {
193
- return spec;
138
+ function createExecutor(execute, options = {}) {
139
+ const middlewares = (options.plugins ?? []).map(pluginToMiddleware);
140
+ return { execute: buildChain(execute, middlewares) };
141
+ }
142
+ function dispatchExecutor(resolver) {
143
+ return {
144
+ execute: (opts) => resolver(opts).execute(opts)
145
+ };
194
146
  }
195
147
 
196
148
  // src/middleware.ts
@@ -201,11 +153,41 @@ var TimeoutError = class extends Error {
201
153
  this.name = "TimeoutError";
202
154
  }
203
155
  };
204
- function defineMiddleware(fn) {
205
- return fn;
156
+ function definePlugin(plugin) {
157
+ return plugin;
158
+ }
159
+ function logger(options) {
160
+ const log = options?.log ?? ((msg, data) => console.log(msg, data));
161
+ const timings = /* @__PURE__ */ new WeakMap();
162
+ return definePlugin({
163
+ name: "logger",
164
+ onRequest: (opts) => {
165
+ timings.set(opts, Date.now());
166
+ log(`[routar] ${opts.method} ${opts.url}`, {
167
+ params: opts.params,
168
+ body: opts.body
169
+ });
170
+ return opts;
171
+ },
172
+ onResponse: (res, opts) => {
173
+ log(
174
+ `[routar] ${opts.method} ${opts.url} \u2014 ${Date.now() - (timings.get(opts) ?? Date.now())}ms`
175
+ );
176
+ timings.delete(opts);
177
+ return res;
178
+ },
179
+ onError: (err, opts) => {
180
+ log(
181
+ `[routar] ${opts.method} ${opts.url} \u2014 error after ${Date.now() - (timings.get(opts) ?? Date.now())}ms`,
182
+ err
183
+ );
184
+ timings.delete(opts);
185
+ throw err;
186
+ }
187
+ });
206
188
  }
207
189
  function withRetry(count, options) {
208
- return defineMiddleware(async (opts, next) => {
190
+ return async (opts, next) => {
209
191
  let lastError;
210
192
  for (let attempt = 0; attempt <= count; attempt++) {
211
193
  try {
@@ -217,10 +199,10 @@ function withRetry(count, options) {
217
199
  }
218
200
  }
219
201
  throw lastError;
220
- });
202
+ };
221
203
  }
222
204
  function withTimeout(ms) {
223
- return defineMiddleware(async (opts, next) => {
205
+ return async (opts, next) => {
224
206
  const controller = new AbortController();
225
207
  const timer = setTimeout(() => controller.abort(new TimeoutError(ms)), ms);
226
208
  const { signal, cleanup } = opts.signal ? anySignal([opts.signal, controller.signal]) : { signal: controller.signal, cleanup: () => {
@@ -231,28 +213,7 @@ function withTimeout(ms) {
231
213
  clearTimeout(timer);
232
214
  cleanup();
233
215
  }
234
- });
235
- }
236
- function withLogger(options) {
237
- const log = options?.log ?? ((msg, data) => console.log(msg, data));
238
- return defineMiddleware(async (opts, next) => {
239
- const start = Date.now();
240
- log(`[routar] ${opts.method} ${opts.url}`, {
241
- params: opts.params,
242
- body: opts.body
243
- });
244
- try {
245
- const result = await next(opts);
246
- log(`[routar] ${opts.method} ${opts.url} \u2014 ${Date.now() - start}ms`);
247
- return result;
248
- } catch (err) {
249
- log(
250
- `[routar] ${opts.method} ${opts.url} \u2014 error after ${Date.now() - start}ms`,
251
- err
252
- );
253
- throw err;
254
- }
255
- });
216
+ };
256
217
  }
257
218
  function anySignal(signals) {
258
219
  const controller = new AbortController();
@@ -274,6 +235,91 @@ function anySignal(signals) {
274
235
  };
275
236
  }
276
237
 
277
- export { HttpError, TimeoutError, ValidationError, createApi, createExecutor, createFetchExecutor, defineMiddleware, defineRouter, dispatchExecutor, endpoint, isRouterDef, joinPaths, resolvePath, serializeParams, withLogger, withRetry, withTimeout };
238
+ // src/utils/params.ts
239
+ function serializeParams(params) {
240
+ const result = new URLSearchParams();
241
+ for (const [key, value] of Object.entries(params)) {
242
+ if (value == null) continue;
243
+ if (Array.isArray(value)) {
244
+ for (const item of value) {
245
+ if (item != null) result.append(key, String(item));
246
+ }
247
+ } else if (typeof value === "object") {
248
+ throw new TypeError(
249
+ `serializeParams: value for key "${key}" is a plain object. Serialize it to a string before passing as a query parameter.`
250
+ );
251
+ } else {
252
+ result.append(key, String(value));
253
+ }
254
+ }
255
+ return result;
256
+ }
257
+
258
+ // src/create-fetch-executor.ts
259
+ function buildFetchChain(transport, retry, timeout) {
260
+ const middlewares = [
261
+ ...retry != null ? [
262
+ typeof retry === "number" ? withRetry(retry) : withRetry(retry.count, { shouldRetry: retry.shouldRetry })
263
+ ] : [],
264
+ ...timeout != null ? [withTimeout(timeout)] : []
265
+ ];
266
+ if (middlewares.length === 0) return transport;
267
+ return buildChain(transport, middlewares);
268
+ }
269
+ function createFetchExecutor(baseURL, options) {
270
+ const transport = async ({
271
+ method,
272
+ url,
273
+ params,
274
+ body,
275
+ headers,
276
+ signal
277
+ }) => {
278
+ const fullURL = new URL(baseURL.replace(/\/$/, "") + url);
279
+ if (params) {
280
+ serializeParams(params).forEach((v, k) => {
281
+ fullURL.searchParams.set(k, v);
282
+ });
283
+ }
284
+ const defaultHeaders = await options?.defaultHeaders?.() ?? {};
285
+ const res = await fetch(fullURL.toString(), {
286
+ method,
287
+ headers: {
288
+ ...defaultHeaders,
289
+ ...headers,
290
+ ...body != null ? { "Content-Type": "application/json" } : {}
291
+ },
292
+ body: body != null ? JSON.stringify(body) : void 0,
293
+ signal
294
+ });
295
+ if (!res.ok) {
296
+ const errorBody = await res.json().catch(() => null);
297
+ throw new HttpError(res.status, res.statusText, errorBody);
298
+ }
299
+ if (res.status === 204 || res.status === 205 || res.status === 304) {
300
+ return null;
301
+ }
302
+ const text = await res.text();
303
+ return text === "" ? null : JSON.parse(text);
304
+ };
305
+ const executor = createExecutor(transport, { plugins: options?.plugins });
306
+ return { execute: buildFetchChain(executor.execute, options?.retry, options?.timeout) };
307
+ }
308
+ var HttpError = class extends Error {
309
+ constructor(status, statusText, body = null) {
310
+ super(`HTTP ${status}: ${statusText}`);
311
+ this.status = status;
312
+ this.statusText = statusText;
313
+ this.body = body;
314
+ this.name = "HttpError";
315
+ }
316
+ };
317
+
318
+ // src/define-endpoint.ts
319
+ function endpoint(spec) {
320
+ return spec;
321
+ }
322
+
323
+ export { HttpError, TimeoutError, ValidationError, createApi, createExecutor, createFetchExecutor, definePlugin, defineRouter, dispatchExecutor, endpoint, isRouterDef, joinPaths, logger, resolvePath, serializeParams };
278
324
  //# sourceMappingURL=index.js.map
279
325
  //# sourceMappingURL=index.js.map