@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.cts 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 };