@btrc/api-server 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bowler Racing
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,54 @@
1
+ # @btrc/api-server
2
+
3
+ Typed server-side (Node 22+/SSR) client for the Bowler Racing BTRC API. Promise-based, non-throwing results, cursor pagination, retry with `retry-after` support. Built on the Zod contracts in [`@btrc/contracts`](https://www.npmjs.com/package/@btrc/contracts).
4
+
5
+ ```bash
6
+ npm install @btrc/api-server
7
+ ```
8
+
9
+ ## Usage
10
+
11
+ ```ts
12
+ import { createBtrcClient, routes } from '@btrc/api-server';
13
+
14
+ const client = createBtrcClient({
15
+ baseUrl: 'https://api.specuro.com', // this is the default
16
+ version: 'v1', // default
17
+ });
18
+
19
+ const result = await client.drivers.list({ limit: 100, season: 2026 });
20
+ if (result.isError) {
21
+ console.error(result.statusCode, result.error);
22
+ } else {
23
+ console.log(result.data, result.meta.hasMore, result.rateLimit);
24
+ }
25
+ ```
26
+
27
+ Every call resolves (never throws) to a discriminated union:
28
+
29
+ ```ts
30
+ { isError: false, data, meta, links, error: null, statusCode, rateLimit }
31
+ { isError: true, data: null, meta: null, links: null, error, statusCode, rateLimit }
32
+ ```
33
+
34
+ `error.kind` is one of `http` (with RFC 9457 `problem`), `transport`, `timeout`, `aborted`, or `parse`.
35
+
36
+ ### Pagination
37
+
38
+ ```ts
39
+ for await (const lap of client.paginate(routes.sessions.laps, { sessionId, valid_only: true })) {
40
+ // follows meta.nextCursor automatically
41
+ }
42
+ ```
43
+
44
+ `client.pages(...)` yields whole page results instead. Iterators throw `BtrcRequestError` on failure.
45
+
46
+ ### Config
47
+
48
+ `{ baseUrl?, version?, fetch?, headers?, timeoutMs?, retry?: { retries, retryOn, baseDelayMs, maxDelayMs }, validate? }` — retries default to 2 attempts on 429/503/transport errors, honouring `retry-after`. `validate: true` runs response envelopes through their Zod schemas.
49
+
50
+ ### Escape hatch
51
+
52
+ `client.request<T>('/some/path', { query })` for anything not in the route table.
53
+
54
+ For browsers and React (`isPending`/`isLoading` states), use [`@btrc/api-client`](https://www.npmjs.com/package/@btrc/api-client).
@@ -0,0 +1,28 @@
1
+ import type { RouteDef, Routes } from '@btrc/contracts';
2
+ import type { ClientConfig, ResolvedConfig } from './config.js';
3
+ import type { PageItem } from './paginate.js';
4
+ import type { RawRequestOptions, RequestOptions, RouteArgs, RouteResult } from './request.js';
5
+ import type { ApiResult } from './result.js';
6
+ type RequiredKeys<T> = {
7
+ [K in keyof T]-?: undefined extends T[K] ? never : K;
8
+ }[keyof T];
9
+ type RouteOp<R extends RouteDef> = [RequiredKeys<RouteArgs<R>>] extends [never] ? (args?: RouteArgs<R>, opts?: RequestOptions) => Promise<RouteResult<R>> : (args: RouteArgs<R>, opts?: RequestOptions) => Promise<RouteResult<R>>;
10
+ type SuccessResult<R extends RouteDef> = Extract<RouteResult<R>, {
11
+ isError: false;
12
+ }>;
13
+ export type BtrcClient = {
14
+ readonly [G in keyof Routes]: {
15
+ readonly [K in keyof Routes[G]]: RouteOp<Routes[G][K] & RouteDef>;
16
+ };
17
+ } & {
18
+ /** Escape hatch: GET an arbitrary path relative to the version segment. */
19
+ request<T = unknown>(path: string, opts?: RawRequestOptions): Promise<ApiResult<T, unknown>>;
20
+ /** Iterate successful page results, following cursors. Throws BtrcRequestError on failure. */
21
+ pages<R extends RouteDef>(route: R, args?: RouteArgs<R>, opts?: RequestOptions): AsyncGenerator<SuccessResult<R>, void>;
22
+ /** Iterate individual items across pages. Throws BtrcRequestError on failure. */
23
+ paginate<R extends RouteDef>(route: R, args?: RouteArgs<R>, opts?: RequestOptions): AsyncGenerator<PageItem<R>, void>;
24
+ readonly config: ResolvedConfig;
25
+ };
26
+ export declare function createBtrcClient(config?: ClientConfig): BtrcClient;
27
+ export {};
28
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AAExD,OAAO,KAAK,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAEhE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAE9C,OAAO,KAAK,EAAE,iBAAiB,EAAE,cAAc,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAC9F,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAE7C,KAAK,YAAY,CAAC,CAAC,IAAI;KAAG,CAAC,IAAI,MAAM,CAAC,CAAC,CAAC,GAAG,SAAS,SAAS,CAAC,CAAC,CAAC,CAAC,GAAG,KAAK,GAAG,CAAC;CAAE,CAAC,MAAM,CAAC,CAAC,CAAC;AAEzF,KAAK,OAAO,CAAC,CAAC,SAAS,QAAQ,IAAI,CAAC,YAAY,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,KAAK,CAAC,GAC3E,CAAC,IAAI,CAAC,EAAE,SAAS,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,EAAE,cAAc,KAAK,OAAO,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,GACvE,CAAC,IAAI,EAAE,SAAS,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,EAAE,cAAc,KAAK,OAAO,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,CAAC;AAE3E,KAAK,aAAa,CAAC,CAAC,SAAS,QAAQ,IAAI,OAAO,CAAC,WAAW,CAAC,CAAC,CAAC,EAAE;IAAE,OAAO,EAAE,KAAK,CAAA;CAAE,CAAC,CAAC;AAErF,MAAM,MAAM,UAAU,GAAG;IACvB,QAAQ,EAAE,CAAC,IAAI,MAAM,MAAM,GAAG;QAAE,QAAQ,EAAE,CAAC,IAAI,MAAM,MAAM,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,QAAQ,CAAC;KAAE;CACpG,GAAG;IACF,2EAA2E;IAC3E,OAAO,CAAC,CAAC,GAAG,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,iBAAiB,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC;IAC7F,8FAA8F;IAC9F,KAAK,CAAC,CAAC,SAAS,QAAQ,EAAE,KAAK,EAAE,CAAC,EAAE,IAAI,CAAC,EAAE,SAAS,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,EAAE,cAAc,GAAG,cAAc,CAAC,aAAa,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC;IACxH,iFAAiF;IACjF,QAAQ,CAAC,CAAC,SAAS,QAAQ,EAAE,KAAK,EAAE,CAAC,EAAE,IAAI,CAAC,EAAE,SAAS,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,EAAE,cAAc,GAAG,cAAc,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC;IACtH,QAAQ,CAAC,MAAM,EAAE,cAAc,CAAC;CACjC,CAAC;AAEF,wBAAgB,gBAAgB,CAAC,MAAM,GAAE,YAAiB,GAAG,UAAU,CAsBtE"}
package/dist/client.js ADDED
@@ -0,0 +1,20 @@
1
+ import { routes } from '@btrc/contracts';
2
+ import { paginateItems, paginatePages } from './paginate.js';
3
+ import { createRequester } from './request.js';
4
+ export function createBtrcClient(config = {}) {
5
+ const requester = createRequester(config);
6
+ const client = {
7
+ config: requester.config,
8
+ request: requester.request,
9
+ pages: (route, args, opts) => paginatePages(requester, route, args, opts),
10
+ paginate: (route, args, opts) => paginateItems(requester, route, args, opts),
11
+ };
12
+ for (const [namespaceName, defs] of Object.entries(routes)) {
13
+ const namespace = {};
14
+ for (const [opName, route] of Object.entries(defs)) {
15
+ namespace[opName] = (args, opts) => requester.call(route, args, opts);
16
+ }
17
+ client[namespaceName] = namespace;
18
+ }
19
+ return client;
20
+ }
@@ -0,0 +1,37 @@
1
+ export interface RetryConfig {
2
+ /** Extra attempts after the first failure. Default 2. */
3
+ retries?: number;
4
+ /** HTTP status codes that trigger a retry. Default [429, 503]. Transport errors always retry. */
5
+ retryOn?: number[];
6
+ /** First backoff delay in ms when no retry-after header is present. Default 250. */
7
+ baseDelayMs?: number;
8
+ /** Backoff ceiling in ms. Default 10_000. */
9
+ maxDelayMs?: number;
10
+ }
11
+ export interface ClientConfig {
12
+ /** Origin of the API, without version segment. Default 'https://api.specuro.com'. */
13
+ baseUrl?: string;
14
+ /** Version path segment. Default 'v1'. */
15
+ version?: string;
16
+ /** Injectable fetch implementation (tests, polyfills). Default globalThis.fetch. */
17
+ fetch?: typeof fetch;
18
+ /** Extra headers sent with every request. */
19
+ headers?: Record<string, string>;
20
+ /** Per-attempt timeout in ms. Default 30_000. */
21
+ timeoutMs?: number;
22
+ retry?: RetryConfig;
23
+ /** When true, response envelopes are validated with their Zod schema. Default false. */
24
+ validate?: boolean;
25
+ }
26
+ export interface ResolvedConfig {
27
+ baseUrl: string;
28
+ version: string;
29
+ fetch: typeof fetch;
30
+ headers: Record<string, string>;
31
+ timeoutMs: number;
32
+ retry: Required<RetryConfig>;
33
+ validate: boolean;
34
+ }
35
+ export declare const DEFAULT_BASE_URL = "https://api.specuro.com";
36
+ export declare function resolveConfig(config?: ClientConfig): ResolvedConfig;
37
+ //# sourceMappingURL=config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,WAAW;IAC1B,yDAAyD;IACzD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,iGAAiG;IACjG,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB,oFAAoF;IACpF,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,6CAA6C;IAC7C,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,YAAY;IAC3B,qFAAqF;IACrF,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,0CAA0C;IAC1C,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,oFAAoF;IACpF,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;IACrB,6CAA6C;IAC7C,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,iDAAiD;IACjD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB,wFAAwF;IACxF,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,OAAO,KAAK,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,QAAQ,CAAC,WAAW,CAAC,CAAC;IAC7B,QAAQ,EAAE,OAAO,CAAC;CACnB;AAED,eAAO,MAAM,gBAAgB,4BAA4B,CAAC;AAE1D,wBAAgB,aAAa,CAAC,MAAM,GAAE,YAAiB,GAAG,cAAc,CAevE"}
package/dist/config.js ADDED
@@ -0,0 +1,17 @@
1
+ export const DEFAULT_BASE_URL = 'https://api.specuro.com';
2
+ export function resolveConfig(config = {}) {
3
+ return {
4
+ baseUrl: (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ''),
5
+ version: (config.version ?? 'v1').replace(/^\/+|\/+$/g, ''),
6
+ fetch: config.fetch ?? globalThis.fetch.bind(globalThis),
7
+ headers: config.headers ?? {},
8
+ timeoutMs: config.timeoutMs ?? 30_000,
9
+ retry: {
10
+ retries: config.retry?.retries ?? 2,
11
+ retryOn: config.retry?.retryOn ?? [429, 503],
12
+ baseDelayMs: config.retry?.baseDelayMs ?? 250,
13
+ maxDelayMs: config.retry?.maxDelayMs ?? 10_000,
14
+ },
15
+ validate: config.validate ?? false,
16
+ };
17
+ }
@@ -0,0 +1,13 @@
1
+ export { createBtrcClient } from './client.js';
2
+ export type { BtrcClient } from './client.js';
3
+ export { createRequester, buildRouteUrl } from './request.js';
4
+ export type { DataOf, MetaOf, RawRequestOptions, Requester, RequestOptions, RouteArgs, RouteResult, } from './request.js';
5
+ export { paginateItems, paginatePages } from './paginate.js';
6
+ export type { PageItem } from './paginate.js';
7
+ export { BtrcRequestError } from './result.js';
8
+ export type { ApiLinks, ApiResult, BtrcError, RateLimitInfo } from './result.js';
9
+ export { DEFAULT_BASE_URL, resolveConfig } from './config.js';
10
+ export type { ClientConfig, ResolvedConfig, RetryConfig } from './config.js';
11
+ export { routes, API_VERSION } from '@btrc/contracts';
12
+ export type { Routes, RouteDef, PathParams, ProblemDetails } from '@btrc/contracts';
13
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAC/C,YAAY,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAC9C,OAAO,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAC9D,YAAY,EACV,MAAM,EACN,MAAM,EACN,iBAAiB,EACjB,SAAS,EACT,cAAc,EACd,SAAS,EACT,WAAW,GACZ,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC7D,YAAY,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAC/C,YAAY,EAAE,QAAQ,EAAE,SAAS,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AACjF,OAAO,EAAE,gBAAgB,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAC9D,YAAY,EAAE,YAAY,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAE7E,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AACtD,YAAY,EAAE,MAAM,EAAE,QAAQ,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export { createBtrcClient } from './client.js';
2
+ export { createRequester, buildRouteUrl } from './request.js';
3
+ export { paginateItems, paginatePages } from './paginate.js';
4
+ export { BtrcRequestError } from './result.js';
5
+ export { DEFAULT_BASE_URL, resolveConfig } from './config.js';
6
+ export { routes, API_VERSION } from '@btrc/contracts';
@@ -0,0 +1,15 @@
1
+ import type { RouteDef } from '@btrc/contracts';
2
+ import type { RequestOptions, Requester, RouteArgs, RouteResult } from './request.js';
3
+ type SuccessResult<R extends RouteDef> = Extract<RouteResult<R>, {
4
+ isError: false;
5
+ }>;
6
+ export type PageItem<R extends RouteDef> = SuccessResult<R>['data'] extends (infer I)[] ? I : never;
7
+ /**
8
+ * Yields each successful page result, following meta.nextCursor while meta.hasMore.
9
+ * Throws BtrcRequestError on any error result (generators cannot return the union usefully).
10
+ */
11
+ export declare function paginatePages<R extends RouteDef>(requester: Requester, route: R, args?: RouteArgs<R>, opts?: RequestOptions): AsyncGenerator<SuccessResult<R>, void>;
12
+ /** Yields individual items across pages. Same error semantics as paginatePages. */
13
+ export declare function paginateItems<R extends RouteDef>(requester: Requester, route: R, args?: RouteArgs<R>, opts?: RequestOptions): AsyncGenerator<PageItem<R>, void>;
14
+ export {};
15
+ //# sourceMappingURL=paginate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"paginate.d.ts","sourceRoot":"","sources":["../src/paginate.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAEhD,OAAO,KAAK,EAAE,cAAc,EAAE,SAAS,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAGtF,KAAK,aAAa,CAAC,CAAC,SAAS,QAAQ,IAAI,OAAO,CAAC,WAAW,CAAC,CAAC,CAAC,EAAE;IAAE,OAAO,EAAE,KAAK,CAAA;CAAE,CAAC,CAAC;AAErF,MAAM,MAAM,QAAQ,CAAC,CAAC,SAAS,QAAQ,IAAI,aAAa,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,EAAE,GAAG,CAAC,GAAG,KAAK,CAAC;AAOpG;;;GAGG;AACH,wBAAuB,aAAa,CAAC,CAAC,SAAS,QAAQ,EACrD,SAAS,EAAE,SAAS,EACpB,KAAK,EAAE,CAAC,EACR,IAAI,CAAC,EAAE,SAAS,CAAC,CAAC,CAAC,EACnB,IAAI,CAAC,EAAE,cAAc,GACpB,cAAc,CAAC,aAAa,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,CAaxC;AAED,mFAAmF;AACnF,wBAAuB,aAAa,CAAC,CAAC,SAAS,QAAQ,EACrD,SAAS,EAAE,SAAS,EACpB,KAAK,EAAE,CAAC,EACR,IAAI,CAAC,EAAE,SAAS,CAAC,CAAC,CAAC,EACnB,IAAI,CAAC,EAAE,cAAc,GACpB,cAAc,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,CAKnC"}
@@ -0,0 +1,27 @@
1
+ import { BtrcRequestError } from './result.js';
2
+ /**
3
+ * Yields each successful page result, following meta.nextCursor while meta.hasMore.
4
+ * Throws BtrcRequestError on any error result (generators cannot return the union usefully).
5
+ */
6
+ export async function* paginatePages(requester, route, args, opts) {
7
+ let cursor;
8
+ do {
9
+ opts?.signal?.throwIfAborted();
10
+ const requestArgs = (cursor === undefined ? { ...args } : { ...args, cursor });
11
+ const result = await requester.call(route, requestArgs, opts);
12
+ if (result.isError) {
13
+ throw new BtrcRequestError(result.error, result.statusCode, result.rateLimit);
14
+ }
15
+ yield result;
16
+ const meta = (result.meta ?? {});
17
+ cursor = meta.hasMore === true && typeof meta.nextCursor === 'string' ? meta.nextCursor : undefined;
18
+ } while (cursor !== undefined);
19
+ }
20
+ /** Yields individual items across pages. Same error semantics as paginatePages. */
21
+ export async function* paginateItems(requester, route, args, opts) {
22
+ for await (const page of paginatePages(requester, route, args, opts)) {
23
+ const items = (Array.isArray(page.data) ? page.data : []);
24
+ for (const item of items)
25
+ yield item;
26
+ }
27
+ }
@@ -0,0 +1,5 @@
1
+ import type { RateLimitInfo } from './result.js';
2
+ export declare function parseRateLimit(headers: Headers): RateLimitInfo;
3
+ /** Returns the retry-after delay in milliseconds, or null when absent/invalid. */
4
+ export declare function parseRetryAfter(headers: Headers): number | null;
5
+ //# sourceMappingURL=rate-limit.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rate-limit.d.ts","sourceRoot":"","sources":["../src/rate-limit.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAEjD,wBAAgB,cAAc,CAAC,OAAO,EAAE,OAAO,GAAG,aAAa,CAU9D;AAED,kFAAkF;AAClF,wBAAgB,eAAe,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAM/D"}
@@ -0,0 +1,23 @@
1
+ export function parseRateLimit(headers) {
2
+ const limitRaw = headers.get('ratelimit-limit');
3
+ const remainingRaw = headers.get('ratelimit-remaining');
4
+ const resetRaw = headers.get('ratelimit-reset');
5
+ if (limitRaw === null || remainingRaw === null || resetRaw === null)
6
+ return null;
7
+ const limit = Number(limitRaw);
8
+ const remaining = Number(remainingRaw);
9
+ const reset = Number(resetRaw);
10
+ if (!Number.isFinite(limit) || !Number.isFinite(remaining) || !Number.isFinite(reset))
11
+ return null;
12
+ return { limit, remaining, reset };
13
+ }
14
+ /** Returns the retry-after delay in milliseconds, or null when absent/invalid. */
15
+ export function parseRetryAfter(headers) {
16
+ const value = headers.get('retry-after');
17
+ if (value === null)
18
+ return null;
19
+ const seconds = Number(value);
20
+ if (Number.isFinite(seconds) && seconds >= 0)
21
+ return seconds * 1000;
22
+ return null;
23
+ }
@@ -0,0 +1,31 @@
1
+ import type { PathParams, RouteDef } from '@btrc/contracts';
2
+ import type { z } from 'zod';
3
+ import type { ClientConfig, ResolvedConfig } from './config.js';
4
+ import type { ApiResult } from './result.js';
5
+ export type RouteArgs<R extends RouteDef> = PathParams<R['path']> & (R['query'] extends z.ZodTypeAny ? z.input<R['query']> : Record<never, never>);
6
+ export type DataOf<R extends RouteDef> = R extends {
7
+ binary: true;
8
+ } ? ArrayBuffer : z.infer<R['response']> extends {
9
+ data: infer D;
10
+ } ? D : unknown;
11
+ export type MetaOf<R extends RouteDef> = R extends {
12
+ binary: true;
13
+ } ? null : z.infer<R['response']> extends {
14
+ meta: infer M;
15
+ } ? M : unknown;
16
+ export type RouteResult<R extends RouteDef> = ApiResult<DataOf<R>, MetaOf<R>>;
17
+ export interface RequestOptions {
18
+ signal?: AbortSignal;
19
+ headers?: Record<string, string>;
20
+ }
21
+ export interface RawRequestOptions extends RequestOptions {
22
+ query?: Record<string, unknown>;
23
+ }
24
+ export interface Requester {
25
+ config: ResolvedConfig;
26
+ call<R extends RouteDef>(route: R, args?: RouteArgs<R>, opts?: RequestOptions): Promise<RouteResult<R>>;
27
+ request<T = unknown>(path: string, opts?: RawRequestOptions): Promise<ApiResult<T, unknown>>;
28
+ }
29
+ export declare function createRequester(config?: ClientConfig): Requester;
30
+ export declare function buildRouteUrl(cfg: ResolvedConfig, route: RouteDef, args?: Record<string, unknown>): string;
31
+ //# sourceMappingURL=request.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"request.d.ts","sourceRoot":"","sources":["../src/request.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAC5D,OAAO,KAAK,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAG7B,OAAO,KAAK,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAEhE,OAAO,KAAK,EAAY,SAAS,EAAa,MAAM,aAAa,CAAC;AAElE,MAAM,MAAM,SAAS,CAAC,CAAC,SAAS,QAAQ,IAAI,UAAU,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,GAC/D,CAAC,CAAC,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,GAAG,MAAM,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC,CAAC;AAEjF,MAAM,MAAM,MAAM,CAAC,CAAC,SAAS,QAAQ,IAAI,CAAC,SAAS;IAAE,MAAM,EAAE,IAAI,CAAA;CAAE,GAC/D,WAAW,GACX,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,SAAS;IAAE,IAAI,EAAE,MAAM,CAAC,CAAA;CAAE,GAC9C,CAAC,GACD,OAAO,CAAC;AAEd,MAAM,MAAM,MAAM,CAAC,CAAC,SAAS,QAAQ,IAAI,CAAC,SAAS;IAAE,MAAM,EAAE,IAAI,CAAA;CAAE,GAC/D,IAAI,GACJ,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,SAAS;IAAE,IAAI,EAAE,MAAM,CAAC,CAAA;CAAE,GAC9C,CAAC,GACD,OAAO,CAAC;AAEd,MAAM,MAAM,WAAW,CAAC,CAAC,SAAS,QAAQ,IAAI,SAAS,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC;AAE9E,MAAM,WAAW,cAAc;IAC7B,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAClC;AAED,MAAM,WAAW,iBAAkB,SAAQ,cAAc;IACvD,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACjC;AAQD,MAAM,WAAW,SAAS;IACxB,MAAM,EAAE,cAAc,CAAC;IACvB,IAAI,CAAC,CAAC,SAAS,QAAQ,EAAE,KAAK,EAAE,CAAC,EAAE,IAAI,CAAC,EAAE,SAAS,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,EAAE,cAAc,GAAG,OAAO,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,CAAC;IACxG,OAAO,CAAC,CAAC,GAAG,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,iBAAiB,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC;CAC9F;AAED,wBAAgB,eAAe,CAAC,MAAM,GAAE,YAAiB,GAAG,SAAS,CAmBpE;AAOD,wBAAgB,aAAa,CAAC,GAAG,EAAE,cAAc,EAAE,KAAK,EAAE,QAAQ,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAgB1G"}
@@ -0,0 +1,171 @@
1
+ import { ProblemDetailsSchema } from '@btrc/contracts';
2
+ import { resolveConfig } from './config.js';
3
+ import { parseRateLimit, parseRetryAfter } from './rate-limit.js';
4
+ export function createRequester(config = {}) {
5
+ const cfg = resolveConfig(config);
6
+ async function call(route, args, opts) {
7
+ const url = buildRouteUrl(cfg, route, args);
8
+ return (await execute(cfg, url, route, opts));
9
+ }
10
+ async function request(path, opts) {
11
+ const url = new URL(joinPath(cfg, path));
12
+ if (opts?.query)
13
+ appendQuery(url, opts.query);
14
+ return (await execute(cfg, url.toString(), null, opts));
15
+ }
16
+ return { config: cfg, call, request };
17
+ }
18
+ function joinPath(cfg, path) {
19
+ const suffix = path === '/' || path === '' ? '' : path.startsWith('/') ? path : `/${path}`;
20
+ return `${cfg.baseUrl}/${cfg.version}${suffix}`;
21
+ }
22
+ export function buildRouteUrl(cfg, route, args) {
23
+ const supplied = { ...(args ?? {}) };
24
+ const interpolated = route.path.replace(/\{([^}]+)\}/g, (_match, name) => {
25
+ const value = supplied[name];
26
+ if (value === undefined || value === null) {
27
+ throw new TypeError(`Missing path parameter "${name}" for ${route.path}`);
28
+ }
29
+ delete supplied[name];
30
+ return encodeURIComponent(String(value));
31
+ });
32
+ const url = new URL(joinPath(cfg, interpolated));
33
+ if (route.query) {
34
+ const parsed = route.query.parse(supplied);
35
+ appendQuery(url, parsed);
36
+ }
37
+ return url.toString();
38
+ }
39
+ function appendQuery(url, params) {
40
+ for (const [key, value] of Object.entries(params)) {
41
+ if (value === undefined || value === null)
42
+ continue;
43
+ if (Array.isArray(value)) {
44
+ if (value.length > 0)
45
+ url.searchParams.set(key, value.map(String).join(','));
46
+ continue;
47
+ }
48
+ url.searchParams.set(key, String(value));
49
+ }
50
+ }
51
+ async function execute(cfg, url, route, opts) {
52
+ const { retries, baseDelayMs, maxDelayMs } = cfg.retry;
53
+ let attempt = 0;
54
+ for (;;) {
55
+ const outcome = await attemptOnce(cfg, url, route, opts);
56
+ if (!outcome.retryable || attempt >= retries)
57
+ return outcome.result;
58
+ const delay = outcome.delayMs ?? backoffDelay(baseDelayMs, maxDelayMs, attempt);
59
+ await sleep(delay);
60
+ attempt += 1;
61
+ }
62
+ }
63
+ async function attemptOnce(cfg, url, route, opts) {
64
+ const timeoutSignal = AbortSignal.timeout(cfg.timeoutMs);
65
+ const signal = opts?.signal ? AbortSignal.any([timeoutSignal, opts.signal]) : timeoutSignal;
66
+ let response;
67
+ try {
68
+ response = await cfg.fetch(url, {
69
+ method: 'GET',
70
+ headers: { accept: 'application/json', ...cfg.headers, ...opts?.headers },
71
+ signal,
72
+ });
73
+ }
74
+ catch (cause) {
75
+ if (opts?.signal?.aborted) {
76
+ return { result: errorResult({ kind: 'aborted' }, null, null), retryable: false, delayMs: null };
77
+ }
78
+ if (timeoutSignal.aborted) {
79
+ return { result: errorResult({ kind: 'timeout' }, null, null), retryable: true, delayMs: null };
80
+ }
81
+ return { result: errorResult({ kind: 'transport', cause }, null, null), retryable: true, delayMs: null };
82
+ }
83
+ const rateLimit = parseRateLimit(response.headers);
84
+ if (!response.ok) {
85
+ const problem = await readProblem(response, url);
86
+ const retryable = cfg.retry.retryOn.includes(response.status);
87
+ return {
88
+ result: errorResult({ kind: 'http', problem }, response.status, rateLimit),
89
+ retryable,
90
+ delayMs: retryable ? parseRetryAfter(response.headers) : null,
91
+ };
92
+ }
93
+ if (route?.binary) {
94
+ const data = await response.arrayBuffer();
95
+ return {
96
+ result: {
97
+ isError: false,
98
+ data,
99
+ meta: null,
100
+ links: { self: url },
101
+ error: null,
102
+ statusCode: response.status,
103
+ rateLimit,
104
+ },
105
+ retryable: false,
106
+ delayMs: null,
107
+ };
108
+ }
109
+ let body;
110
+ try {
111
+ body = await response.json();
112
+ }
113
+ catch (cause) {
114
+ return { result: errorResult({ kind: 'parse', cause }, response.status, rateLimit), retryable: false, delayMs: null };
115
+ }
116
+ if (cfg.validate && route) {
117
+ const checked = route.response.safeParse(body);
118
+ if (!checked.success) {
119
+ return {
120
+ result: errorResult({ kind: 'parse', cause: checked.error }, response.status, rateLimit),
121
+ retryable: false,
122
+ delayMs: null,
123
+ };
124
+ }
125
+ body = checked.data;
126
+ }
127
+ const envelope = (body ?? {});
128
+ return {
129
+ result: {
130
+ isError: false,
131
+ data: envelope.data,
132
+ meta: envelope.meta,
133
+ links: envelope.links ?? { self: url },
134
+ error: null,
135
+ statusCode: response.status,
136
+ rateLimit,
137
+ },
138
+ retryable: false,
139
+ delayMs: null,
140
+ };
141
+ }
142
+ async function readProblem(response, url) {
143
+ let body = null;
144
+ try {
145
+ body = await response.json();
146
+ }
147
+ catch {
148
+ body = null;
149
+ }
150
+ const parsed = ProblemDetailsSchema.safeParse(body);
151
+ if (parsed.success)
152
+ return parsed.data;
153
+ return {
154
+ type: 'about:blank',
155
+ title: response.statusText || 'Request failed',
156
+ status: response.status,
157
+ detail: `Request to ${url} failed with status ${response.status}`,
158
+ instance: new URL(url).pathname,
159
+ requestId: response.headers.get('x-request-id') ?? 'unknown',
160
+ };
161
+ }
162
+ function errorResult(error, statusCode, rateLimit) {
163
+ return { isError: true, data: null, meta: null, links: null, error, statusCode, rateLimit };
164
+ }
165
+ function backoffDelay(baseDelayMs, maxDelayMs, attempt) {
166
+ const exponential = Math.min(maxDelayMs, baseDelayMs * 2 ** attempt);
167
+ return exponential + Math.random() * exponential * 0.2;
168
+ }
169
+ function sleep(ms) {
170
+ return new Promise((resolve) => setTimeout(resolve, ms));
171
+ }
@@ -0,0 +1,52 @@
1
+ import type { ProblemDetails } from '@btrc/contracts';
2
+ /** Parsed `ratelimit-*` response headers; null when the server sent none. */
3
+ export type RateLimitInfo = {
4
+ limit: number;
5
+ remaining: number;
6
+ /** Epoch seconds at which the current window resets. */
7
+ reset: number;
8
+ } | null;
9
+ export type BtrcError = {
10
+ kind: 'http';
11
+ problem: ProblemDetails;
12
+ } | {
13
+ kind: 'transport';
14
+ cause: unknown;
15
+ } | {
16
+ kind: 'timeout';
17
+ } | {
18
+ kind: 'aborted';
19
+ } | {
20
+ kind: 'parse';
21
+ cause: unknown;
22
+ };
23
+ export type ApiLinks = {
24
+ self: string;
25
+ next?: string | null;
26
+ };
27
+ export type ApiResult<TData, TMeta> = {
28
+ isError: false;
29
+ data: TData;
30
+ meta: TMeta;
31
+ links: ApiLinks;
32
+ error: null;
33
+ statusCode: number;
34
+ rateLimit: RateLimitInfo;
35
+ } | {
36
+ isError: true;
37
+ data: null;
38
+ meta: null;
39
+ links: null;
40
+ error: BtrcError;
41
+ statusCode: number | null;
42
+ rateLimit: RateLimitInfo;
43
+ };
44
+ /** Thrown by iterators and the react layer, which cannot return result unions. */
45
+ export declare class BtrcRequestError extends Error {
46
+ readonly error: BtrcError;
47
+ readonly statusCode: number | null;
48
+ readonly rateLimit: RateLimitInfo;
49
+ constructor(error: BtrcError, statusCode: number | null, rateLimit?: RateLimitInfo);
50
+ get problem(): ProblemDetails | null;
51
+ }
52
+ //# sourceMappingURL=result.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"result.d.ts","sourceRoot":"","sources":["../src/result.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAEtD,6EAA6E;AAC7E,MAAM,MAAM,aAAa,GAAG;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,wDAAwD;IACxD,KAAK,EAAE,MAAM,CAAC;CACf,GAAG,IAAI,CAAC;AAET,MAAM,MAAM,SAAS,GACjB;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,cAAc,CAAA;CAAE,GACzC;IAAE,IAAI,EAAE,WAAW,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,GACrC;IAAE,IAAI,EAAE,SAAS,CAAA;CAAE,GACnB;IAAE,IAAI,EAAE,SAAS,CAAA;CAAE,GACnB;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,CAAC;AAEtC,MAAM,MAAM,QAAQ,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAAC;AAE9D,MAAM,MAAM,SAAS,CAAC,KAAK,EAAE,KAAK,IAC9B;IACE,OAAO,EAAE,KAAK,CAAC;IACf,IAAI,EAAE,KAAK,CAAC;IACZ,IAAI,EAAE,KAAK,CAAC;IACZ,KAAK,EAAE,QAAQ,CAAC;IAChB,KAAK,EAAE,IAAI,CAAC;IACZ,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,aAAa,CAAC;CAC1B,GACD;IACE,OAAO,EAAE,IAAI,CAAC;IACd,IAAI,EAAE,IAAI,CAAC;IACX,IAAI,EAAE,IAAI,CAAC;IACX,KAAK,EAAE,IAAI,CAAC;IACZ,KAAK,EAAE,SAAS,CAAC;IACjB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,SAAS,EAAE,aAAa,CAAC;CAC1B,CAAC;AAEN,kFAAkF;AAClF,qBAAa,gBAAiB,SAAQ,KAAK;IACzC,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAC;IAC1B,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IACnC,QAAQ,CAAC,SAAS,EAAE,aAAa,CAAC;IAElC,YAAY,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,GAAG,IAAI,EAAE,SAAS,GAAE,aAAoB,EAMvF;IAED,IAAI,OAAO,IAAI,cAAc,GAAG,IAAI,CAEnC;CACF"}
package/dist/result.js ADDED
@@ -0,0 +1,30 @@
1
+ /** Thrown by iterators and the react layer, which cannot return result unions. */
2
+ export class BtrcRequestError extends Error {
3
+ error;
4
+ statusCode;
5
+ rateLimit;
6
+ constructor(error, statusCode, rateLimit = null) {
7
+ super(describeError(error));
8
+ this.name = 'BtrcRequestError';
9
+ this.error = error;
10
+ this.statusCode = statusCode;
11
+ this.rateLimit = rateLimit;
12
+ }
13
+ get problem() {
14
+ return this.error.kind === 'http' ? this.error.problem : null;
15
+ }
16
+ }
17
+ function describeError(error) {
18
+ switch (error.kind) {
19
+ case 'http':
20
+ return `BTRC API request failed: ${error.problem.status} ${error.problem.title}`;
21
+ case 'transport':
22
+ return 'BTRC API request failed: network error';
23
+ case 'timeout':
24
+ return 'BTRC API request failed: timed out';
25
+ case 'aborted':
26
+ return 'BTRC API request failed: aborted';
27
+ case 'parse':
28
+ return 'BTRC API request failed: response did not match the expected envelope';
29
+ }
30
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@btrc/api-server",
3
+ "version": "0.0.2",
4
+ "description": "Typed server-side (Node/SSR) client for the Bowler Racing BTRC API",
5
+ "private": false,
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "license": "MIT",
9
+ "engines": {
10
+ "node": ">=22.13"
11
+ },
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/ReeceCenturion/BTRC.git",
15
+ "directory": "packages/api-server"
16
+ },
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "files": [
21
+ "dist"
22
+ ],
23
+ "exports": {
24
+ ".": {
25
+ "types": "./dist/index.d.ts",
26
+ "default": "./dist/index.js"
27
+ }
28
+ },
29
+ "dependencies": {
30
+ "zod": "^3.25.67",
31
+ "@btrc/contracts": "^0.0.2"
32
+ },
33
+ "devDependencies": {
34
+ "@types/node": "^22.10.0",
35
+ "rimraf": "^6.0.1",
36
+ "typescript": "7.0.2",
37
+ "vitest": "^3.2.4"
38
+ },
39
+ "scripts": {
40
+ "build": "tsc -p tsconfig.build.json",
41
+ "dev": "tsc -p tsconfig.build.json --watch",
42
+ "typecheck": "tsc --noEmit",
43
+ "lint": "echo lint skipped: typescript-eslint does not yet support TypeScript 7 (TODO: restore eslint . when @typescript-eslint ships TS7 support)",
44
+ "test": "vitest run",
45
+ "clean": "rimraf .turbo dist coverage"
46
+ }
47
+ }