@routar/core 1.2.1 → 1.4.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
  *
@@ -116,7 +146,7 @@ interface RouterDef<TEndpoints extends RouterEndpoints = RouterEndpoints> {
116
146
  * ```
117
147
  */
118
148
  type ApiTypes<TApi> = {
119
- [K in keyof TApi]: TApi[K] extends (...args: any[]) => Promise<infer R> ? {
149
+ [K in keyof TApi as K extends "$router" ? never : K]: TApi[K] extends (...args: any[]) => Promise<infer R> ? {
120
150
  request: Parameters<TApi[K]>[0];
121
151
  response: R;
122
152
  } : TApi[K] extends object ? ApiTypes<TApi[K]> : never;
@@ -158,6 +188,17 @@ type EndpointFn<TSpec extends EndpointSpec<any, any, any>> = TSpec["request"] ex
158
188
  type ApiClient<TEndpoints extends RouterEndpoints> = {
159
189
  [K in keyof TEndpoints]: TEndpoints[K] extends RouterDef<infer TNestedEndpoints> ? ApiClient<TNestedEndpoints> : TEndpoints[K] extends EndpointSpec<any, any, any> ? EndpointFn<TEndpoints[K]> : never;
160
190
  };
191
+ /**
192
+ * An {@link ApiClient} that also carries its source {@link RouterDef} on the
193
+ * `$router` property. This is the actual return type of {@link createApi};
194
+ * downstream tools (e.g. `@routar/react-query`) recover the router (prefix +
195
+ * endpoint methods) from it without it being re-passed. `$router` is
196
+ * non-enumerable and excluded from {@link ApiTypes}; the `$` prefix keeps it
197
+ * from colliding with endpoint names.
198
+ */
199
+ type ApiClientWithRouter<TEndpoints extends RouterEndpoints> = ApiClient<TEndpoints> & {
200
+ readonly $router: RouterDef<TEndpoints>;
201
+ };
161
202
  /**
162
203
  * Builds a fully-typed API client from an {@link Executor} and a router
163
204
  * (or bare endpoint map).
@@ -213,71 +254,59 @@ type ApiClient<TEndpoints extends RouterEndpoints> = {
213
254
  * });
214
255
  * ```
215
256
  */
216
- declare function createApi<TEndpoints extends RouterEndpoints>(executor: Executor, router: RouterDef<TEndpoints>, options?: CreateApiOptions): ApiClient<TEndpoints>;
257
+ declare function createApi<TEndpoints extends RouterEndpoints>(executor: Executor, router: RouterDef<TEndpoints>, options?: CreateApiOptions): ApiClientWithRouter<TEndpoints>;
217
258
  /**
218
259
  * @param executor - Transport to use for every HTTP call.
219
260
  * @param prefix - URL prefix prepended to every endpoint path.
220
261
  * @param endpoints - Record of named endpoint specs.
221
262
  * @param options - Optional settings.
222
263
  */
223
- declare function createApi<TEndpoints extends RouterEndpoints>(executor: Executor, prefix: string, endpoints: TEndpoints, options?: CreateApiOptions): ApiClient<TEndpoints>;
264
+ declare function createApi<TEndpoints extends RouterEndpoints>(executor: Executor, prefix: string, endpoints: TEndpoints, options?: CreateApiOptions): ApiClientWithRouter<TEndpoints>;
224
265
  /**
225
266
  * @param executor - Transport to use for every HTTP call.
226
267
  * @param endpoints - Record of named endpoint specs (no URL prefix).
227
268
  * @param options - Optional settings.
228
269
  */
229
- declare function createApi<TEndpoints extends RouterEndpoints>(executor: Executor, endpoints: TEndpoints, options?: CreateApiOptions): ApiClient<TEndpoints>;
270
+ declare function createApi<TEndpoints extends RouterEndpoints>(executor: Executor, endpoints: TEndpoints, options?: CreateApiOptions): ApiClientWithRouter<TEndpoints>;
230
271
 
231
272
  /**
232
- * Creates an {@link Executor} by wrapping a transport function with an
233
- * optional middleware chain.
273
+ * Creates an {@link Executor} by wrapping a transport function with plugins.
234
274
  *
235
- * Middlewares are applied in declaration order — the first middleware is the
236
- * outermost wrapper and runs first on each request.
275
+ * Plugins run in declaration order (first plugin is outermost).
237
276
  *
238
- * @param execute - The underlying transport function (fetch, axios, etc.).
239
- * @param middlewares - Ordered list of middlewares to apply.
277
+ * For `retry` and `timeout`, use the options on {@link createFetchExecutor}
278
+ * or configure them via the underlying HTTP client (axios, ky).
240
279
  *
241
280
  * @example
242
281
  * ```ts
243
- * const executor = createExecutor(
244
- * async ({ method, url, body }) => {
245
- * const res = await fetch(url, { method, body: JSON.stringify(body) });
246
- * return res.json();
247
- * },
248
- * [withTimeout(5000), withRetry(3), withLogger()],
249
- * );
282
+ * const executor = createExecutor(transport, {
283
+ * plugins: [authPlugin, logger()],
284
+ * });
250
285
  * ```
251
286
  */
252
- declare function createExecutor(execute: (options: ExecuteOptions) => Promise<unknown>, middlewares?: ExecutorMiddleware[]): Executor;
287
+ declare function createExecutor(execute: (options: ExecuteOptions) => Promise<unknown>, options?: CreateExecutorOptions): Executor;
253
288
  /**
254
289
  * Creates an {@link Executor} that selects the underlying transport at
255
290
  * request time based on the result of `resolver`.
256
291
  *
257
- * Use this to unify SSR and CSR behind a single API client — the resolver
258
- * picks the right executor per request, so `createApi` is called once and
259
- * works in both environments without duplicate `*ServerApi` instances.
260
- *
261
- * The resolver receives the full {@link ExecuteOptions} so it can branch on
262
- * environment, URL prefix, auth context, or any runtime condition.
263
- *
264
- * @param resolver - Called on every request; returns the executor to delegate to.
265
- *
266
292
  * @example
267
293
  * ```ts
268
- * // SSR vs CSR — pick transport based on environment
269
294
  * const apiExecutor = dispatchExecutor(() =>
270
295
  * typeof window === 'undefined' ? serverExecutor : clientExecutor,
271
296
  * );
272
- *
273
- * // Route by URL prefix — internal routes use a different transport
274
- * const apiExecutor = dispatchExecutor((opts) =>
275
- * opts.url.startsWith('/internal') ? internalExecutor : publicExecutor,
276
- * );
277
297
  * ```
278
298
  */
279
299
  declare function dispatchExecutor(resolver: (opts: ExecuteOptions) => Executor): Executor;
280
300
 
301
+ type FetchRetryOption = number | {
302
+ count: number;
303
+ shouldRetry?: (error: unknown, attempt: number) => boolean;
304
+ };
305
+ interface FetchExecutorOptions extends CreateExecutorOptions {
306
+ defaultHeaders?: () => Record<string, string> | Promise<Record<string, string>>;
307
+ retry?: FetchRetryOption;
308
+ timeout?: number;
309
+ }
281
310
  /**
282
311
  * Creates an {@link Executor} backed by the browser / Node.js `fetch` API.
283
312
  *
@@ -293,7 +322,13 @@ declare function dispatchExecutor(resolver: (opts: ExecuteOptions) => Executor):
293
322
  * @param baseURL - Absolute base URL prepended to every endpoint path.
294
323
  * @param options.defaultHeaders - Async factory called on every request to
295
324
  * produce headers (e.g. reading cookies in a Next.js server component).
296
- * @param options.middlewares - Middleware chain applied before the fetch call.
325
+ * @param options.plugins - Plugins applied around the fetch call. Each retry
326
+ * attempt re-runs `onRequest` hooks (so headers are refreshed per attempt)
327
+ * and `onError` hooks. Token-refresh-on-401 patterns work by updating an
328
+ * external token store in `onError` and letting `onRequest` pick it up on
329
+ * the next attempt.
330
+ * @param options.retry - Number of retries, or `{ count, shouldRetry? }`.
331
+ * @param options.timeout - Per-attempt timeout in milliseconds.
297
332
  *
298
333
  * @example Minimal — no options needed
299
334
  * ```ts
@@ -310,22 +345,15 @@ declare function dispatchExecutor(resolver: (opts: ExecuteOptions) => Executor):
310
345
  * });
311
346
  * ```
312
347
  *
313
- * @example Next.js App Router — forward cookies from the incoming request
348
+ * @example With retry and timeout
314
349
  * ```ts
315
350
  * const executor = createFetchExecutor('https://api.example.com', {
316
- * defaultHeaders: async () => {
317
- * const { cookies } = await import('next/headers');
318
- * const token = (await cookies()).get('access_token')?.value;
319
- * return token ? { Authorization: `Bearer ${token}` } : {};
320
- * },
321
- * middlewares: [withTimeout(8_000), withRetry(2)],
351
+ * retry: 2,
352
+ * timeout: 8_000,
322
353
  * });
323
354
  * ```
324
355
  */
325
- declare function createFetchExecutor(baseURL: string, options?: {
326
- defaultHeaders?: () => Record<string, string> | Promise<Record<string, string>>;
327
- middlewares?: ExecutorMiddleware[];
328
- }): Executor;
356
+ declare function createFetchExecutor(baseURL: string, options?: FetchExecutorOptions): Executor;
329
357
  /**
330
358
  * Thrown by {@link createFetchExecutor} when the server returns a non-2xx
331
359
  * status code.
@@ -342,19 +370,10 @@ declare function createFetchExecutor(baseURL: string, options?: {
342
370
  * ```
343
371
  */
344
372
  declare class HttpError extends Error {
345
- /** HTTP status code (e.g. 404, 500). */
346
373
  readonly status: number;
347
- /** HTTP status text (e.g. "Not Found"). */
348
374
  readonly statusText: string;
349
- /** Parsed response body, or `null` if the body was empty or not JSON. */
350
375
  readonly body: unknown;
351
- constructor(
352
- /** HTTP status code (e.g. 404, 500). */
353
- status: number,
354
- /** HTTP status text (e.g. "Not Found"). */
355
- statusText: string,
356
- /** Parsed response body, or `null` if the body was empty or not JSON. */
357
- body?: unknown);
376
+ constructor(status: number, statusText: string, body?: unknown);
358
377
  }
359
378
 
360
379
  /**
@@ -387,6 +406,10 @@ type PathConstraint<TPath extends string> = [PathParams<TPath>] extends [never]
387
406
  * that `request` includes a matching `path` field with those param names.
388
407
  * A mismatch or missing key is a compile-time error.
389
408
  *
409
+ * The literal HTTP method (`'GET'`, `'POST'`, …) is preserved on the return
410
+ * type — `endpoint({ method: 'GET', ... }).method` is typed `'GET'`, not the
411
+ * `HttpMethod` union.
412
+ *
390
413
  * @example Basic GET with no params
391
414
  * ```ts
392
415
  * const getList = endpoint({ method: 'GET', path: '/', response: z.array(TodoSchema) });
@@ -442,47 +465,47 @@ type PathConstraint<TPath extends string> = [PathParams<TPath>] extends [never]
442
465
  * });
443
466
  * ```
444
467
  */
445
- declare function endpoint<TPath extends string, TRequest extends RequestShape & PathConstraint<TPath>, TResponse extends Validator<unknown>, TOut>(spec: {
446
- method: HttpMethod;
468
+ declare function endpoint<TMethod extends HttpMethod, TPath extends string, TRequest extends RequestShape & PathConstraint<TPath>, TResponse extends Validator<unknown>, TOut>(spec: {
469
+ method: TMethod;
447
470
  path: TPath;
448
471
  request: Validator<TRequest>;
449
472
  response: TResponse;
450
473
  adapter: (raw: ValidatorOutput<TResponse>) => TOut;
451
474
  }): {
452
- method: HttpMethod;
475
+ method: TMethod;
453
476
  path: string;
454
477
  request: Validator<TRequest>;
455
478
  response: TResponse;
456
479
  adapter: (raw: ValidatorOutput<TResponse>) => TOut;
457
480
  };
458
- declare function endpoint<TPath extends string, TRequest extends RequestShape & PathConstraint<TPath>, TResponse extends Validator<unknown>>(spec: {
459
- method: HttpMethod;
481
+ declare function endpoint<TMethod extends HttpMethod, TPath extends string, TRequest extends RequestShape & PathConstraint<TPath>, TResponse extends Validator<unknown>>(spec: {
482
+ method: TMethod;
460
483
  path: TPath;
461
484
  request: Validator<TRequest>;
462
485
  response: TResponse;
463
486
  }): {
464
- method: HttpMethod;
487
+ method: TMethod;
465
488
  path: string;
466
489
  request: Validator<TRequest>;
467
490
  response: TResponse;
468
491
  };
469
- declare function endpoint<TResponse extends Validator<unknown>, TOut>(spec: {
470
- method: HttpMethod;
492
+ declare function endpoint<TMethod extends HttpMethod, TResponse extends Validator<unknown>, TOut>(spec: {
493
+ method: TMethod;
471
494
  path: string;
472
495
  response: TResponse;
473
496
  adapter: (raw: ValidatorOutput<TResponse>) => TOut;
474
497
  }): {
475
- method: HttpMethod;
498
+ method: TMethod;
476
499
  path: string;
477
500
  response: TResponse;
478
501
  adapter: (raw: ValidatorOutput<TResponse>) => TOut;
479
502
  };
480
- declare function endpoint<TResponse extends Validator<unknown>>(spec: {
481
- method: HttpMethod;
503
+ declare function endpoint<TMethod extends HttpMethod, TResponse extends Validator<unknown>>(spec: {
504
+ method: TMethod;
482
505
  path: string;
483
506
  response: TResponse;
484
507
  }): {
485
- method: HttpMethod;
508
+ method: TMethod;
486
509
  path: string;
487
510
  response: TResponse;
488
511
  };
@@ -522,71 +545,29 @@ declare function isRouterDef(entry: object): entry is RouterDef<RouterEndpoints>
522
545
  declare function defineRouter<TEndpoints extends RouterEndpoints>(prefix: string, endpoints: TEndpoints): RouterDef<TEndpoints>;
523
546
 
524
547
  /**
525
- * Thrown by {@link withTimeout} when a request exceeds the configured duration.
526
- * Distinguishable from a user-initiated {@link AbortSignal} cancellation.
548
+ * Thrown by the built-in `timeout` option when a request exceeds the
549
+ * configured duration. Distinguishable from a user-initiated
550
+ * {@link AbortSignal} cancellation.
527
551
  */
528
552
  declare class TimeoutError extends Error {
529
553
  readonly ms: number;
530
554
  constructor(ms: number);
531
555
  }
532
556
  /**
533
- * Identity helper that returns the middleware as-is.
534
- *
535
- * Wrap your middleware function with this to get full type inference on `opts`
536
- * and `next` without having to annotate the type manually.
537
- *
538
- * @example
539
- * ```ts
540
- * const withCorrelationId = defineMiddleware((opts, next) =>
541
- * next({ ...opts, headers: { ...opts.headers, 'X-Request-Id': crypto.randomUUID() } })
542
- * );
543
- * ```
544
- */
545
- declare function defineMiddleware(fn: ExecutorMiddleware): ExecutorMiddleware;
546
- /**
547
- * Retries a failed request up to `count` additional times.
548
- *
549
- * By default all errors trigger a retry. Pass `shouldRetry` to skip retries
550
- * for non-transient errors (e.g. 4xx responses).
551
- *
552
- * @param count - Number of retries (not counting the initial attempt).
553
- * @param options.shouldRetry - Return `false` to stop retrying early.
554
- * Receives the error and a zero-based `attempt` index (0 = first failure,
555
- * 1 = second failure, …) so you can limit retries by count or error type.
556
- *
557
- * @example
558
- * ```ts
559
- * withRetry(3, {
560
- * shouldRetry: (err) => err instanceof HttpError && err.status >= 500,
561
- * })
562
- * ```
563
- */
564
- declare function withRetry(count: number, options?: {
565
- shouldRetry?: (error: unknown, attempt: number) => boolean;
566
- }): ExecutorMiddleware;
567
- /**
568
- * Aborts a request if it does not complete within `ms` milliseconds.
569
- *
570
- * Merges the timeout signal with any existing `AbortSignal` on the request,
571
- * so whichever fires first wins.
572
- *
573
- * @param ms - Timeout in milliseconds.
557
+ * Identity helper that returns the plugin as-is, providing full type inference.
574
558
  *
575
559
  * @example
576
560
  * ```ts
577
- * const executor = createFetchExecutor('https://api.example.com', {
578
- * middlewares: [withTimeout(5_000)],
561
+ * const authPlugin = definePlugin({
562
+ * name: 'auth',
563
+ * onRequest: async (opts) => ({
564
+ * ...opts,
565
+ * headers: { ...opts.headers, Authorization: `Bearer ${await getToken()}` },
566
+ * }),
579
567
  * });
580
- *
581
- * // Combine with retry — timeout applies per attempt
582
- * const executor = createExecutor(transport, [
583
- * withTimeout(5_000),
584
- * withRetry(3, { shouldRetry: (err) => !(err instanceof HttpError && err.status < 500) }),
585
- * withLogger(),
586
- * ]);
587
568
  * ```
588
569
  */
589
- declare function withTimeout(ms: number): ExecutorMiddleware;
570
+ declare function definePlugin(plugin: ExecutorPlugin): ExecutorPlugin;
590
571
  /**
591
572
  * Logs each request and its outcome (success duration or error).
592
573
  *
@@ -594,12 +575,14 @@ declare function withTimeout(ms: number): ExecutorMiddleware;
594
575
  *
595
576
  * @example
596
577
  * ```ts
597
- * withLogger({ log: (msg, data) => logger.debug(msg, data) })
578
+ * createExecutor(transport, {
579
+ * plugins: [logger({ log: (msg, data) => myLogger.debug(msg, data) })],
580
+ * })
598
581
  * ```
599
582
  */
600
- declare function withLogger(options?: {
583
+ declare function logger(options?: {
601
584
  log?: (message: string, data?: unknown) => void;
602
- }): ExecutorMiddleware;
585
+ }): ExecutorPlugin;
603
586
 
604
587
  declare function serializeParams(params: Record<string, unknown>): URLSearchParams;
605
588
 
@@ -618,4 +601,4 @@ declare class ValidationError extends Error {
618
601
  constructor(message: string, cause?: unknown);
619
602
  }
620
603
 
621
- 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 };
604
+ export { type ApiClient, type ApiClientWithRouter, 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 };