@wynn-dev/better-fetch-rpc 0.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nguyên
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,454 @@
1
+ <a id="readme-top"></a>
2
+
3
+ <!-- PROJECT SHIELDS -->
4
+
5
+ [![NPM Version](https://img.shields.io/npm/v/better-fetch-rpc.svg?style=for-the-badge)](https://www.npmjs.com/package/better-fetch-rpc)
6
+ [![NPM Downloads](https://img.shields.io/npm/dm/better-fetch-rpc.svg?style=for-the-badge)](https://www.npmjs.com/package/better-fetch-rpc)
7
+ [![Contributors](https://img.shields.io/github/contributors/nbnguyen75/better-fetch-rpc.svg?style=for-the-badge)](https://github.com/nbnguyen75/better-fetch-rpc/graphs/contributors)
8
+ [![Forks](https://img.shields.io/github/forks/nbnguyen75/better-fetch-rpc.svg?style=for-the-badge)](https://github.com/nbnguyen75/better-fetch-rpc/network/members)
9
+ [![Stargazers](https://img.shields.io/github/stars/nbnguyen75/better-fetch-rpc.svg?style=for-the-badge)](https://github.com/nbnguyen75/better-fetch-rpc/stargazers)
10
+ [![Issues](https://img.shields.io/github/issues/nbnguyen75/better-fetch-rpc.svg?style=for-the-badge)](https://github.com/nbnguyen75/better-fetch-rpc/issues)
11
+ [![MIT License](https://img.shields.io/github/license/nbnguyen75/better-fetch-rpc.svg?style=for-the-badge)](https://github.com/nbnguyen75/better-fetch-rpc/blob/main/LICENSE)
12
+
13
+ <!-- PROJECT LOGO -->
14
+ <br />
15
+ <div align="center">
16
+ <h3 align="center">better-fetch-rpc</h3>
17
+
18
+ <p align="center">
19
+ A type-safe, RPC-style wrapper around <code>fetch</code> — call your API like a local function, with full type inference.
20
+ <br />
21
+ <a href="https://github.com/nbnguyen75/better-fetch-rpc"><strong>Explore the docs »</strong></a>
22
+ <br />
23
+ <br />
24
+ <a href="https://github.com/nbnguyen75/better-fetch-rpc/issues/new?labels=bug&template=bug-report---.md">Report Bug</a>
25
+ ·
26
+ <a href="https://github.com/nbnguyen75/better-fetch-rpc/issues/new?labels=enhancement&template=feature-request---.md">Request Feature</a>
27
+ </p>
28
+ </div>
29
+
30
+ <!-- TABLE OF CONTENTS -->
31
+ <details>
32
+ <summary>Table of Contents</summary>
33
+ <ol>
34
+ <li>
35
+ <a href="#about-the-project">About The Project</a>
36
+ <ul>
37
+ <li><a href="#built-with">Built With</a></li>
38
+ </ul>
39
+ </li>
40
+ <li>
41
+ <a href="#getting-started">Getting Started</a>
42
+ <ul>
43
+ <li><a href="#prerequisites">Prerequisites</a></li>
44
+ <li><a href="#installation">Installation</a></li>
45
+ </ul>
46
+ </li>
47
+ <li>
48
+ <a href="#usage">Usage</a>
49
+ <ul>
50
+ <li><a href="#1-define-your-routes">Define your routes</a></li>
51
+ <li><a href="#2-create-the-client-and-call-it">Create the client and call it</a></li>
52
+ <li><a href="#response-shapes">Response shapes</a></li>
53
+ <li><a href="#typing-the-error-channel">Typing the error channel</a></li>
54
+ <li><a href="#reusing-endpoint-types">Reusing endpoint types</a></li>
55
+ <li><a href="#client-options">Client options</a></li>
56
+ <li><a href="#schema-compatibility">Schema compatibility</a></li>
57
+ <li><a href="#runtime-response-validation">Runtime response validation</a></li>
58
+ <li><a href="#tanstack-query">TanStack Query</a></li>
59
+ </ul>
60
+ </li>
61
+ <li><a href="#api-reference">API Reference</a></li>
62
+ <li><a href="#roadmap">Roadmap</a></li>
63
+ <li><a href="#contributing">Contributing</a></li>
64
+ <li><a href="#license">License</a></li>
65
+ <li><a href="#contact">Contact</a></li>
66
+ </ol>
67
+ </details>
68
+
69
+ <!-- ABOUT THE PROJECT -->
70
+
71
+ ## About The Project
72
+
73
+ **better-fetch-rpc** is a type-safe RPC-style wrapper around `fetch`. Instead of hand-writing URL strings, headers, and response parsing for every request, you define your API routes once and call them like local functions — with types inferred end-to-end.
74
+
75
+ Schema types (zod, valibot, arktype, …) are understood through [Standard Schema](https://standardschema.dev) and can optionally validate responses at runtime. The package itself has zero runtime dependencies besides its `@better-fetch/fetch` peer.
76
+
77
+ <p align="right">(<a href="#readme-top">back to top</a>)</p>
78
+
79
+ ### Built With
80
+
81
+ - [![TypeScript][TypeScript-badge]][TypeScript-url]
82
+ - [![Node.js][Node-badge]][Node-url]
83
+ - [better-fetch](https://github.com/better-auth/better-fetch) — fetch engine with native Standard Schema validation
84
+
85
+ <p align="right">(<a href="#readme-top">back to top</a>)</p>
86
+
87
+ <!-- GETTING STARTED -->
88
+
89
+ ## Getting Started
90
+
91
+ ### Prerequisites
92
+
93
+ - Node.js ≥ 18
94
+
95
+ ### Installation
96
+
97
+ ```sh
98
+ npm install better-fetch-rpc
99
+ # or
100
+ pnpm add better-fetch-rpc
101
+ # or
102
+ yarn add better-fetch-rpc
103
+ ```
104
+
105
+ `@better-fetch/fetch` is a peer dependency and is installed automatically by
106
+ npm, pnpm, and yarn. Runtime response validation needs a peer of at least
107
+ `v1.1.21` (the first version with Standard Schema `output` support). A schema
108
+ library (zod, valibot, …) is only needed if you use runtime response
109
+ validation.
110
+
111
+ <p align="right">(<a href="#readme-top">back to top</a>)</p>
112
+
113
+ <!-- USAGE EXAMPLES -->
114
+
115
+ ## Usage
116
+
117
+ ### 1. Define your routes
118
+
119
+ A router maps paths to HTTP methods (`$get`, `$post`, `$put`, `$patch`,
120
+ `$delete`). Each endpoint declares its `headers`, `params`, `query`, `body`,
121
+ and `response` — as plain TypeScript types or as schemas from any
122
+ Standard Schema library:
123
+
124
+ ```ts
125
+ import type { EnsureRouter } from 'better-fetch-rpc';
126
+
127
+ import { z } from 'zod';
128
+
129
+ const noteSchema = z.object({ id: z.string(), title: z.string() });
130
+
131
+ type Note = z.infer<typeof noteSchema>;
132
+ type ApiSuccessResponse<T> = { success: true; data: T };
133
+
134
+ type Router = EnsureRouter<{
135
+ '/api/v1/notes': {
136
+ $get: {
137
+ response: ApiSuccessResponse<Note[]>;
138
+ query?: { limit?: number } | undefined;
139
+ };
140
+ $post: {
141
+ response: ApiSuccessResponse<Note>;
142
+ body: { title: string };
143
+ };
144
+ };
145
+ '/api/v1/notes/:id': {
146
+ $get: {
147
+ response: typeof noteSchema;
148
+ params: { id: string };
149
+ };
150
+ };
151
+ }>;
152
+ ```
153
+
154
+ Endpoints that declare nothing — or only optional fields — accept zero
155
+ arguments; required fields must be passed.
156
+
157
+ ### 2. Create the client and call it
158
+
159
+ Path segments become properties. `:id`-style segments are indexed with the
160
+ literal key and filled from `params` (which better-fetch also substitutes
161
+ into the URL):
162
+
163
+ ```ts
164
+ import { createRpcClient } from 'better-fetch-rpc';
165
+
166
+ const api = createRpcClient<Router>('https://api.example.com');
167
+
168
+ // Fully typed request + response
169
+ const { data, error } = await api.api.v1.notes[':id'].$get({
170
+ params: { id: '123' },
171
+ });
172
+
173
+ if (error) {
174
+ console.error(error);
175
+ } else {
176
+ console.log(data); // typed from the route definition
177
+ }
178
+ ```
179
+
180
+ ### Response shapes
181
+
182
+ By default every call resolves to `{ data, error }` — exactly one of them is
183
+ non-null. Pass `throw: true` to receive the response data directly and let
184
+ transport failures reject instead:
185
+
186
+ ```ts
187
+ const throwing = createRpcClient<Router>('https://api.example.com', { throw: true });
188
+
189
+ const notes = await throwing.api.v1.notes.$get({ query: { limit: 10 } });
190
+ // ^ typed as the $get response (no envelope)
191
+ ```
192
+
193
+ ### Typing the error channel
194
+
195
+ Pass your server's error shape as the second generic so `error` is typed:
196
+
197
+ ```ts
198
+ type ApiError = { errorCode: string; message: string };
199
+
200
+ const api = createRpcClient<Router, ApiError>('https://api.example.com');
201
+
202
+ const { error } = await api.api.v1.notes.$get();
203
+ if (error) {
204
+ console.error(error.errorCode); // string
205
+ }
206
+ ```
207
+
208
+ ### Reusing endpoint types
209
+
210
+ `InferRequestType` / `InferResponseType` extract an endpoint's options and
211
+ resolved value — handy for wrapping calls in your own functions:
212
+
213
+ ```ts
214
+ import type { InferRequestType, InferResponseType } from 'better-fetch-rpc';
215
+
216
+ type GetNotesRequest = InferRequestType<typeof api.api.v1.notes.$get>;
217
+ type GetNotesResponse = InferResponseType<typeof api.api.v1.notes.$get>['data'];
218
+
219
+ export async function getNotes(args: GetNotesRequest): Promise<GetNotesResponse> {
220
+ const result = await api.api.v1.notes.$get(args);
221
+ if (result.error) throw new Error('Request failed');
222
+ return result.data;
223
+ }
224
+ ```
225
+
226
+ ### Client options
227
+
228
+ The second argument accepts everything
229
+ [`@better-fetch/fetch`](https://github.com/better-auth/better-fetch) accepts
230
+ (`auth`, `headers`, `retry`, `timeout`, …) — except `baseURL`/`body`, which
231
+ the client owns — plus `schemas` (see below):
232
+
233
+ ```ts
234
+ const api = createRpcClient<Router>('https://api.example.com', {
235
+ auth: { token: () => getToken(), type: 'Bearer' },
236
+ });
237
+ ```
238
+
239
+ ### Schema compatibility
240
+
241
+ Request/response types are inferred through [Standard Schema](https://standardschema.dev),
242
+ so any compliant library works — no hard dependency on a specific one:
243
+
244
+ - zod ≥ 3.24
245
+ - valibot ≥ 1.0
246
+ - arktype ≥ 2.0
247
+ - …anything exposing `~standard`
248
+
249
+ Plain TypeScript types work too — schemas are optional, not required.
250
+
251
+ ### Runtime response validation
252
+
253
+ Types alone can't verify what the server actually sends. To validate responses
254
+ at runtime, define routes as a const once — feeding both the router type and
255
+ the runtime schemas — and pass it via the `schemas` option:
256
+
257
+ ```ts
258
+ import { z } from 'zod';
259
+ import { ValidationError, createRpcClient } from 'better-fetch-rpc';
260
+
261
+ const noteSchema = z.object({ id: z.string(), title: z.string() });
262
+
263
+ const routes = {
264
+ '/api/v1/notes/:id': {
265
+ $get: { response: noteSchema },
266
+ },
267
+ } as const;
268
+
269
+ type Router = EnsureRouter<typeof routes>;
270
+
271
+ const api = createRpcClient<Router>('https://api.example.com', { schemas: routes });
272
+
273
+ try {
274
+ const { data, error } = await api.api.v1.notes[':id'].$get({ params: { id: '123' } });
275
+ if (error) {
276
+ console.error('Request failed:', error);
277
+ } else {
278
+ console.log(data.title); // validated: guaranteed to match noteSchema
279
+ }
280
+ } catch (error) {
281
+ if (error instanceof ValidationError) {
282
+ console.error('Server broke the contract:', error.issues);
283
+ }
284
+ throw error;
285
+ }
286
+ ```
287
+
288
+ Rules:
289
+
290
+ - Only routes with a runtime schema are validated; everything else passes
291
+ through untouched (inference-only, zero cost).
292
+ - Validation failures **always throw** `ValidationError` (re-exported for
293
+ convenience) — in both `throw: true` and default modes, just like network
294
+ errors. The `{ data, error }` channel is reserved for server responses.
295
+ - Schemas declared as bare types (no runtime instance) are inference-only;
296
+ only `typeof mySchema` entries paired with `schemas` can be validated.
297
+
298
+ ### TanStack Query
299
+
300
+ The `throw: true` client pairs naturally with TanStack Query v5: transport and
301
+ validation failures reject, so they land in Query's `error` / `onError`
302
+ channel with no envelope unwrapping. Define `queryOptions` factories once,
303
+ then consume them in hooks:
304
+
305
+ ```ts
306
+ import { queryOptions } from '@tanstack/react-query';
307
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
308
+
309
+ const throwing = createRpcClient<Router>('https://api.example.com', { throw: true });
310
+
311
+ export const noteKeys = {
312
+ all: ['notes'] as const,
313
+ list: (params?: { limit?: number }) => [...noteKeys.all, 'list', params] as const,
314
+ detail: (id: string) => [...noteKeys.all, 'detail', id] as const,
315
+ };
316
+
317
+ export function noteDetailQueryOptions(id: string) {
318
+ return queryOptions({
319
+ queryFn: () => throwing.api.v1.notes[':id'].$get({ params: { id } }),
320
+ queryKey: noteKeys.detail(id),
321
+ });
322
+ }
323
+
324
+ export function useNote(id: string) {
325
+ return useQuery(noteDetailQueryOptions(id));
326
+ }
327
+
328
+ export function useCreateNote() {
329
+ const queryClient = useQueryClient();
330
+ return useMutation({
331
+ mutationFn: (title: string) => throwing.api.v1.notes.$post({ body: { title } }),
332
+ onSuccess: () => {
333
+ void queryClient.invalidateQueries({ queryKey: noteKeys.all });
334
+ },
335
+ });
336
+ }
337
+ ```
338
+
339
+ `ValidationError` from a validated route surfaces the same way — as the
340
+ query/mutation `error` — because validation failures always throw.
341
+
342
+ _For more examples and the full API reference, please refer to the [Documentation](https://github.com/nbnguyen75/better-fetch-rpc)._
343
+
344
+ <p align="right">(<a href="#readme-top">back to top</a>)</p>
345
+
346
+ <!-- API REFERENCE -->
347
+
348
+ ## API Reference
349
+
350
+ ### Values
351
+
352
+ | Export | Description |
353
+ | ------------------ | --------------------------------------------------------------------------------------------- |
354
+ | `createRpcClient` | `createRpcClient<Router, Error = unknown>(baseURL?, options?)` — builds the typed RPC client. |
355
+ | `ValidationError` | Re-exported from `@better-fetch/fetch`. Thrown on response validation failure; has `.issues`. |
356
+ | `isStandardSchema` | Type guard for Standard Schema instances. |
357
+
358
+ ### Types
359
+
360
+ | Export | Description |
361
+ | ----------------------- | ----------------------------------------------------------------------- |
362
+ | `EnsureRouter<T>` | Constrains a route map to the router shape. |
363
+ | `BaseRouter` | `Record<string, MethodMap>` — the base constraint for routers. |
364
+ | `EndpointDef` | One endpoint: `headers` / `params` / `query` / `body` / `response`. |
365
+ | `MethodMap` | `$get` / `$post` / `$put` / `$patch` / `$delete` endpoint map. |
366
+ | `ProxyTree<R, T, E>` | The client type derived from a router. |
367
+ | `RpcResponse<D, E>` | `{ data: D; error: null } \| { data: null; error: E }`. |
368
+ | `RpcSchemas<R>` | Runtime schemas mirror for the `schemas` option. |
369
+ | `RouteSchemas` | Per-endpoint schemas: `{ response?: StandardSchemaV1 }`. |
370
+ | `CreateRpcClientOption` | Client options (better-fetch options + `schemas`). |
371
+ | `RequestOptions` | Per-call options: `headers` / `params` / `query` / `body`. |
372
+ | `HttpMethod` | `'DELETE' \| 'PATCH' \| 'POST' \| 'GET' \| 'PUT'`. |
373
+ | `InferRequestType<F>` | Extracts an endpoint function's options type. |
374
+ | `InferResponseType<F>` | Extracts an endpoint function's resolved value type. |
375
+ | `StandardSchemaV1` | Vendored Standard Schema interface (no dependency needed to reference). |
376
+ | `InferStandardInput` | Infer a schema's input type. |
377
+ | `InferStandardOutput` | Infer a schema's output type. |
378
+
379
+ <p align="right">(<a href="#readme-top">back to top</a>)</p>
380
+
381
+ <!-- ROADMAP -->
382
+
383
+ ## Roadmap
384
+
385
+ - [x] Core RPC client (GET/POST/PUT/PATCH/DELETE)
386
+ - [x] Opt-in Standard Schema runtime response validation
387
+ - [ ] Request/response interceptors
388
+ - [ ] Built-in retry & timeout handling
389
+
390
+ See the [open issues](https://github.com/nbnguyen75/better-fetch-rpc/issues) for a full list of proposed features and known issues.
391
+
392
+ <p align="right">(<a href="#readme-top">back to top</a>)</p>
393
+
394
+ <!-- CONTRIBUTING -->
395
+
396
+ ## Contributing
397
+
398
+ Contributions make the open source community amazing. Any contributions you make are **greatly appreciated**.
399
+
400
+ If you have a suggestion, fork the repo and open a pull request, or open an issue with the tag "enhancement". Don't forget to star the project!
401
+
402
+ 1. Fork the Project
403
+ 2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`)
404
+ 3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`)
405
+ 4. Push to the Branch (`git push origin feature/AmazingFeature`)
406
+ 5. Open a Pull Request
407
+
408
+ ### Changesets
409
+
410
+ Every PR that should trigger a release must include a changeset: run
411
+ `pnpm changeset` and commit the generated file. The release workflow turns
412
+ them into version bumps and `CHANGELOG.md` entries automatically.
413
+
414
+ ### Package managers
415
+
416
+ Use **pnpm** or **bun** — never npm. After any dependency change, run both
417
+ `pnpm install` and `bun install` so `pnpm-lock.yaml` and `bun.lock` stay in
418
+ sync. Never mix managers in one `node_modules`: switching means deleting
419
+ `node_modules` and reinstalling. CI runs pnpm only, and `pnpm test` is the
420
+ source of truth (`bun run test` executes vitest under Bun, which is
421
+ unsupported — use Bun for `bun dist/index.js` smoke runs instead).
422
+
423
+ ### Emergency local publish
424
+
425
+ Releases normally ship from CI via OIDC trusted publishing. If CI is
426
+ unavailable, `pnpm publish` works locally with an automation token, but the
427
+ release will lack a provenance attestation — prefer CI.
428
+
429
+ <p align="right">(<a href="#readme-top">back to top</a>)</p>
430
+
431
+ <!-- LICENSE -->
432
+
433
+ ## License
434
+
435
+ Distributed under the MIT License. See `LICENSE` for more information.
436
+
437
+ <p align="right">(<a href="#readme-top">back to top</a>)</p>
438
+
439
+ <!-- CONTACT -->
440
+
441
+ ## Contact
442
+
443
+ Nguyên (Wynn) - [GitHub @nbnguyen75](https://github.com/nbnguyen75)
444
+
445
+ Project Link: [https://github.com/nbnguyen75/better-fetch-rpc](https://github.com/nbnguyen75/better-fetch-rpc)
446
+
447
+ <p align="right">(<a href="#readme-top">back to top</a>)</p>
448
+
449
+ <!-- MARKDOWN LINKS & IMAGES -->
450
+
451
+ [TypeScript-badge]: https://img.shields.io/badge/typescript-3178C6?style=for-the-badge&logo=typescript&logoColor=white
452
+ [TypeScript-url]: https://www.typescriptlang.org/
453
+ [Node-badge]: https://img.shields.io/badge/node.js-339933?style=for-the-badge&logo=node.js&logoColor=white
454
+ [Node-url]: https://nodejs.org/
package/dist/index.cjs ADDED
@@ -0,0 +1,64 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let _better_fetch_fetch = require("@better-fetch/fetch");
3
+ //#region src/standard-schema.ts
4
+ function isStandardSchema(value) {
5
+ if (typeof value !== "object" || value === null || !("~standard" in value)) return false;
6
+ const standard = value["~standard"];
7
+ return typeof standard === "object" && standard !== null;
8
+ }
9
+ //#endregion
10
+ //#region src/rpc.ts
11
+ const METHOD_KEY_MAP = {
12
+ $delete: "DELETE",
13
+ $patch: "PATCH",
14
+ $post: "POST",
15
+ $get: "GET",
16
+ $put: "PUT"
17
+ };
18
+ const METHOD_TO_KEY = {
19
+ DELETE: "$delete",
20
+ GET: "$get",
21
+ PATCH: "$patch",
22
+ POST: "$post",
23
+ PUT: "$put"
24
+ };
25
+ function createProxyClient(makeRequest, segments = []) {
26
+ return new Proxy(() => {}, { get(_target, prop) {
27
+ if (prop in METHOD_KEY_MAP) {
28
+ const method = METHOD_KEY_MAP[prop];
29
+ if (!method) return void 0;
30
+ const path = "/" + segments.join("/");
31
+ return (options) => makeRequest(method, path, options);
32
+ }
33
+ return createProxyClient(makeRequest, [...segments, prop]);
34
+ } });
35
+ }
36
+ function createRpcClient(baseURL, option = {}) {
37
+ const { schemas, ...fetchOption } = option;
38
+ const $fetchBase = (0, _better_fetch_fetch.createFetch)({
39
+ ...baseURL ? { baseURL } : {},
40
+ ...fetchOption
41
+ });
42
+ const schemaIndex = schemas;
43
+ const makeRequest = (method, path, options) => {
44
+ const responseSchema = schemaIndex?.[path]?.[METHOD_TO_KEY[method]]?.response;
45
+ return $fetchBase(path, {
46
+ headers: options?.headers,
47
+ params: options?.params,
48
+ query: options?.query,
49
+ body: options?.body,
50
+ method,
51
+ ...isStandardSchema(responseSchema) ? { output: responseSchema } : {}
52
+ });
53
+ };
54
+ return createProxyClient(makeRequest);
55
+ }
56
+ //#endregion
57
+ Object.defineProperty(exports, "ValidationError", {
58
+ enumerable: true,
59
+ get: function() {
60
+ return _better_fetch_fetch.ValidationError;
61
+ }
62
+ });
63
+ exports.createRpcClient = createRpcClient;
64
+ exports.isStandardSchema = isStandardSchema;
@@ -0,0 +1,100 @@
1
+ import { CreateFetchOption, ValidationError } from "@better-fetch/fetch";
2
+ //#region src/standard-schema.d.ts
3
+ /**
4
+ * Standard Schema V1 — vendored type definitions (no runtime code).
5
+ *
6
+ * Source: https://github.com/standard-schema/standard-schema (v1 spec).
7
+ * Vendored so this package stays dependency-free: any schema library that
8
+ * implements the spec (zod ≥ 3.24, valibot ≥ 1.0, arktype ≥ 2.0, …) is
9
+ * structurally compatible without this package depending on it.
10
+ *
11
+ * Only the shapes needed for type inference and (future) response
12
+ * validation are included. Kept flat (no namespaces) to stay compatible
13
+ * with `erasableSyntaxOnly`.
14
+ */
15
+ interface StandardSchemaPathSegment {
16
+ readonly key: PropertyKey;
17
+ }
18
+ interface StandardSchemaIssue {
19
+ readonly message: string;
20
+ readonly path?: ReadonlyArray<PropertyKey | StandardSchemaPathSegment> | undefined;
21
+ }
22
+ interface StandardSchemaSuccessResult<Output> {
23
+ readonly value: Output;
24
+ readonly issues?: undefined;
25
+ }
26
+ interface StandardSchemaFailureResult {
27
+ readonly issues: ReadonlyArray<StandardSchemaIssue>;
28
+ }
29
+ type StandardSchemaResult<Output> = StandardSchemaFailureResult | StandardSchemaSuccessResult<Output>;
30
+ interface StandardSchemaTypes<Input = unknown, Output = Input> {
31
+ readonly input: Input;
32
+ readonly output: Output;
33
+ }
34
+ interface StandardSchemaV1<Input = unknown, Output = Input> {
35
+ readonly '~standard': {
36
+ readonly version: 1;
37
+ readonly vendor: string;
38
+ readonly validate: (value: unknown) => StandardSchemaResult<Output> | Promise<StandardSchemaResult<Output>>;
39
+ readonly types?: StandardSchemaTypes<Input, Output> | undefined;
40
+ };
41
+ }
42
+ type InferStandardInput<Schema extends StandardSchemaV1> = NonNullable<Schema['~standard']['types']>['input'];
43
+ type InferStandardOutput<Schema extends StandardSchemaV1> = NonNullable<Schema['~standard']['types']>['output'];
44
+ export declare function isStandardSchema(value: unknown): value is StandardSchemaV1;
45
+ //#endregion
46
+ //#region src/rpc.d.ts
47
+ type InferSchema<T> = T extends StandardSchemaV1 ? InferStandardOutput<T> : T;
48
+ type EndpointDef = {
49
+ headers?: Record<string, string | undefined> | StandardSchemaV1;
50
+ query?: Record<string, unknown> | StandardSchemaV1 | undefined;
51
+ params?: Record<string, unknown> | StandardSchemaV1;
52
+ response?: unknown;
53
+ body?: unknown;
54
+ };
55
+ type MethodMap = {
56
+ $delete?: EndpointDef;
57
+ $patch?: EndpointDef;
58
+ $post?: EndpointDef;
59
+ $get?: EndpointDef;
60
+ $put?: EndpointDef;
61
+ };
62
+ type InferRequest<T extends EndpointDef> = { [K in keyof T as K extends 'body' | 'headers' | 'params' | 'query' ? T[K] extends never ? never : K : never]: InferSchema<T[K]>; };
63
+ type RequestArgs<Endpoint extends EndpointDef> = Record<string, never> extends InferRequest<Endpoint> ? [options?: InferRequest<Endpoint>] : [options: InferRequest<Endpoint>];
64
+ type RequestOptions = {
65
+ headers?: Record<string, string | undefined>;
66
+ params?: Record<string, unknown>;
67
+ query?: Record<string, unknown>;
68
+ body?: Record<string, unknown>;
69
+ };
70
+ type Split<S extends string> = S extends `${infer Head}/${infer Tail}` ? Head extends '' ? Split<Tail> : [Head, ...Split<Tail>] : S extends '' ? [] : [S];
71
+ type UnionToIntersection<U> = (U extends unknown ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never;
72
+ type RpcResponse<Data, Error = unknown> = {
73
+ error: Error;
74
+ data: null;
75
+ } | {
76
+ error: null;
77
+ data: Data;
78
+ };
79
+ type MethodClient<Methods extends MethodMap, Throw extends boolean, Error = unknown> = { [M in keyof Methods as Methods[M] extends EndpointDef ? M : never]: Methods[M] extends EndpointDef ? (...args: RequestArgs<Methods[M]>) => Promise<Throw extends true ? InferResponse<Methods[M]> : RpcResponse<InferResponse<Methods[M]>, Error>> : never; };
80
+ type BuildBranch<Segments extends ReadonlyArray<string>, Leaf> = Segments extends readonly [infer Head extends string, ...infer Rest extends ReadonlyArray<string>] ? Rest extends readonly [] ? { [K in Head]: Leaf; } : { [K in Head]: BuildBranch<Rest, Leaf>; } : never;
81
+ type BaseRouter = Record<string, MethodMap>;
82
+ type ProxyTree<Router extends BaseRouter, Throw extends boolean = false, Error = unknown> = UnionToIntersection<{ [Path in keyof Router]: Path extends string ? BuildBranch<Split<Path>, MethodClient<Router[Path], Throw, Error>> : never; }[keyof Router]>;
83
+ type HttpMethod = 'DELETE' | 'PATCH' | 'POST' | 'GET' | 'PUT';
84
+ type InferRequestType<T extends (...args: Array<never>) => unknown> = Parameters<T>[0];
85
+ type InferResponseType<T extends (...args: Array<never>) => unknown> = Awaited<ReturnType<T>>;
86
+ type InferResponse<T extends EndpointDef> = InferSchema<T['response']>;
87
+ type EnsureRouter<T extends BaseRouter> = T;
88
+ type RouteSchemas = {
89
+ response?: StandardSchemaV1 | undefined;
90
+ };
91
+ type RpcSchemas<Router extends BaseRouter> = { [Path in keyof Router]?: { [Method in keyof Router[Path]]?: RouteSchemas | undefined; }; };
92
+ type CreateRpcClientOption<Router extends BaseRouter = BaseRouter> = Omit<CreateFetchOption, 'baseURL' | 'body'> & {
93
+ schemas?: RpcSchemas<Router> | undefined;
94
+ };
95
+ export declare function createRpcClient<Router extends BaseRouter, Error = unknown>(baseURL: undefined | string, option: CreateRpcClientOption<Router> & {
96
+ throw: true;
97
+ }): ProxyTree<Router, true, Error>;
98
+ export declare function createRpcClient<Router extends BaseRouter, Error = unknown>(baseURL?: string, option?: CreateRpcClientOption<Router>): ProxyTree<Router, false, Error>;
99
+ //#endregion
100
+ export { type BaseRouter, type CreateRpcClientOption, type EndpointDef, type EnsureRouter, type HttpMethod, type InferRequestType, type InferResponseType, type InferStandardInput, type InferStandardOutput, type MethodMap, type ProxyTree, type RequestOptions, type RouteSchemas, type RpcResponse, type RpcSchemas, type StandardSchemaFailureResult, type StandardSchemaIssue, type StandardSchemaPathSegment, type StandardSchemaResult, type StandardSchemaSuccessResult, type StandardSchemaTypes, type StandardSchemaV1, ValidationError };
@@ -0,0 +1,100 @@
1
+ import { CreateFetchOption, ValidationError } from "@better-fetch/fetch";
2
+ //#region src/standard-schema.d.ts
3
+ /**
4
+ * Standard Schema V1 — vendored type definitions (no runtime code).
5
+ *
6
+ * Source: https://github.com/standard-schema/standard-schema (v1 spec).
7
+ * Vendored so this package stays dependency-free: any schema library that
8
+ * implements the spec (zod ≥ 3.24, valibot ≥ 1.0, arktype ≥ 2.0, …) is
9
+ * structurally compatible without this package depending on it.
10
+ *
11
+ * Only the shapes needed for type inference and (future) response
12
+ * validation are included. Kept flat (no namespaces) to stay compatible
13
+ * with `erasableSyntaxOnly`.
14
+ */
15
+ interface StandardSchemaPathSegment {
16
+ readonly key: PropertyKey;
17
+ }
18
+ interface StandardSchemaIssue {
19
+ readonly message: string;
20
+ readonly path?: ReadonlyArray<PropertyKey | StandardSchemaPathSegment> | undefined;
21
+ }
22
+ interface StandardSchemaSuccessResult<Output> {
23
+ readonly value: Output;
24
+ readonly issues?: undefined;
25
+ }
26
+ interface StandardSchemaFailureResult {
27
+ readonly issues: ReadonlyArray<StandardSchemaIssue>;
28
+ }
29
+ type StandardSchemaResult<Output> = StandardSchemaFailureResult | StandardSchemaSuccessResult<Output>;
30
+ interface StandardSchemaTypes<Input = unknown, Output = Input> {
31
+ readonly input: Input;
32
+ readonly output: Output;
33
+ }
34
+ interface StandardSchemaV1<Input = unknown, Output = Input> {
35
+ readonly '~standard': {
36
+ readonly version: 1;
37
+ readonly vendor: string;
38
+ readonly validate: (value: unknown) => StandardSchemaResult<Output> | Promise<StandardSchemaResult<Output>>;
39
+ readonly types?: StandardSchemaTypes<Input, Output> | undefined;
40
+ };
41
+ }
42
+ type InferStandardInput<Schema extends StandardSchemaV1> = NonNullable<Schema['~standard']['types']>['input'];
43
+ type InferStandardOutput<Schema extends StandardSchemaV1> = NonNullable<Schema['~standard']['types']>['output'];
44
+ export declare function isStandardSchema(value: unknown): value is StandardSchemaV1;
45
+ //#endregion
46
+ //#region src/rpc.d.ts
47
+ type InferSchema<T> = T extends StandardSchemaV1 ? InferStandardOutput<T> : T;
48
+ type EndpointDef = {
49
+ headers?: Record<string, string | undefined> | StandardSchemaV1;
50
+ query?: Record<string, unknown> | StandardSchemaV1 | undefined;
51
+ params?: Record<string, unknown> | StandardSchemaV1;
52
+ response?: unknown;
53
+ body?: unknown;
54
+ };
55
+ type MethodMap = {
56
+ $delete?: EndpointDef;
57
+ $patch?: EndpointDef;
58
+ $post?: EndpointDef;
59
+ $get?: EndpointDef;
60
+ $put?: EndpointDef;
61
+ };
62
+ type InferRequest<T extends EndpointDef> = { [K in keyof T as K extends 'body' | 'headers' | 'params' | 'query' ? T[K] extends never ? never : K : never]: InferSchema<T[K]>; };
63
+ type RequestArgs<Endpoint extends EndpointDef> = Record<string, never> extends InferRequest<Endpoint> ? [options?: InferRequest<Endpoint>] : [options: InferRequest<Endpoint>];
64
+ type RequestOptions = {
65
+ headers?: Record<string, string | undefined>;
66
+ params?: Record<string, unknown>;
67
+ query?: Record<string, unknown>;
68
+ body?: Record<string, unknown>;
69
+ };
70
+ type Split<S extends string> = S extends `${infer Head}/${infer Tail}` ? Head extends '' ? Split<Tail> : [Head, ...Split<Tail>] : S extends '' ? [] : [S];
71
+ type UnionToIntersection<U> = (U extends unknown ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never;
72
+ type RpcResponse<Data, Error = unknown> = {
73
+ error: Error;
74
+ data: null;
75
+ } | {
76
+ error: null;
77
+ data: Data;
78
+ };
79
+ type MethodClient<Methods extends MethodMap, Throw extends boolean, Error = unknown> = { [M in keyof Methods as Methods[M] extends EndpointDef ? M : never]: Methods[M] extends EndpointDef ? (...args: RequestArgs<Methods[M]>) => Promise<Throw extends true ? InferResponse<Methods[M]> : RpcResponse<InferResponse<Methods[M]>, Error>> : never; };
80
+ type BuildBranch<Segments extends ReadonlyArray<string>, Leaf> = Segments extends readonly [infer Head extends string, ...infer Rest extends ReadonlyArray<string>] ? Rest extends readonly [] ? { [K in Head]: Leaf; } : { [K in Head]: BuildBranch<Rest, Leaf>; } : never;
81
+ type BaseRouter = Record<string, MethodMap>;
82
+ type ProxyTree<Router extends BaseRouter, Throw extends boolean = false, Error = unknown> = UnionToIntersection<{ [Path in keyof Router]: Path extends string ? BuildBranch<Split<Path>, MethodClient<Router[Path], Throw, Error>> : never; }[keyof Router]>;
83
+ type HttpMethod = 'DELETE' | 'PATCH' | 'POST' | 'GET' | 'PUT';
84
+ type InferRequestType<T extends (...args: Array<never>) => unknown> = Parameters<T>[0];
85
+ type InferResponseType<T extends (...args: Array<never>) => unknown> = Awaited<ReturnType<T>>;
86
+ type InferResponse<T extends EndpointDef> = InferSchema<T['response']>;
87
+ type EnsureRouter<T extends BaseRouter> = T;
88
+ type RouteSchemas = {
89
+ response?: StandardSchemaV1 | undefined;
90
+ };
91
+ type RpcSchemas<Router extends BaseRouter> = { [Path in keyof Router]?: { [Method in keyof Router[Path]]?: RouteSchemas | undefined; }; };
92
+ type CreateRpcClientOption<Router extends BaseRouter = BaseRouter> = Omit<CreateFetchOption, 'baseURL' | 'body'> & {
93
+ schemas?: RpcSchemas<Router> | undefined;
94
+ };
95
+ export declare function createRpcClient<Router extends BaseRouter, Error = unknown>(baseURL: undefined | string, option: CreateRpcClientOption<Router> & {
96
+ throw: true;
97
+ }): ProxyTree<Router, true, Error>;
98
+ export declare function createRpcClient<Router extends BaseRouter, Error = unknown>(baseURL?: string, option?: CreateRpcClientOption<Router>): ProxyTree<Router, false, Error>;
99
+ //#endregion
100
+ export { type BaseRouter, type CreateRpcClientOption, type EndpointDef, type EnsureRouter, type HttpMethod, type InferRequestType, type InferResponseType, type InferStandardInput, type InferStandardOutput, type MethodMap, type ProxyTree, type RequestOptions, type RouteSchemas, type RpcResponse, type RpcSchemas, type StandardSchemaFailureResult, type StandardSchemaIssue, type StandardSchemaPathSegment, type StandardSchemaResult, type StandardSchemaSuccessResult, type StandardSchemaTypes, type StandardSchemaV1, ValidationError };
package/dist/index.js ADDED
@@ -0,0 +1,56 @@
1
+ import { ValidationError, createFetch } from "@better-fetch/fetch";
2
+ //#region src/standard-schema.ts
3
+ function isStandardSchema(value) {
4
+ if (typeof value !== "object" || value === null || !("~standard" in value)) return false;
5
+ const standard = value["~standard"];
6
+ return typeof standard === "object" && standard !== null;
7
+ }
8
+ //#endregion
9
+ //#region src/rpc.ts
10
+ const METHOD_KEY_MAP = {
11
+ $delete: "DELETE",
12
+ $patch: "PATCH",
13
+ $post: "POST",
14
+ $get: "GET",
15
+ $put: "PUT"
16
+ };
17
+ const METHOD_TO_KEY = {
18
+ DELETE: "$delete",
19
+ GET: "$get",
20
+ PATCH: "$patch",
21
+ POST: "$post",
22
+ PUT: "$put"
23
+ };
24
+ function createProxyClient(makeRequest, segments = []) {
25
+ return new Proxy(() => {}, { get(_target, prop) {
26
+ if (prop in METHOD_KEY_MAP) {
27
+ const method = METHOD_KEY_MAP[prop];
28
+ if (!method) return void 0;
29
+ const path = "/" + segments.join("/");
30
+ return (options) => makeRequest(method, path, options);
31
+ }
32
+ return createProxyClient(makeRequest, [...segments, prop]);
33
+ } });
34
+ }
35
+ function createRpcClient(baseURL, option = {}) {
36
+ const { schemas, ...fetchOption } = option;
37
+ const $fetchBase = createFetch({
38
+ ...baseURL ? { baseURL } : {},
39
+ ...fetchOption
40
+ });
41
+ const schemaIndex = schemas;
42
+ const makeRequest = (method, path, options) => {
43
+ const responseSchema = schemaIndex?.[path]?.[METHOD_TO_KEY[method]]?.response;
44
+ return $fetchBase(path, {
45
+ headers: options?.headers,
46
+ params: options?.params,
47
+ query: options?.query,
48
+ body: options?.body,
49
+ method,
50
+ ...isStandardSchema(responseSchema) ? { output: responseSchema } : {}
51
+ });
52
+ };
53
+ return createProxyClient(makeRequest);
54
+ }
55
+ //#endregion
56
+ export { ValidationError, createRpcClient, isStandardSchema };
package/package.json ADDED
@@ -0,0 +1,77 @@
1
+ {
2
+ "name": "@wynn-dev/better-fetch-rpc",
3
+ "version": "0.0.0",
4
+ "description": "Type-safe RPC-style client built on @better-fetch/fetch — define routes once, call them like local functions.",
5
+ "keywords": [
6
+ "better-fetch",
7
+ "fetch",
8
+ "rpc",
9
+ "typed-client",
10
+ "typescript"
11
+ ],
12
+ "homepage": "https://github.com/nbnguyen75/better-fetch-rpc#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/nbnguyen75/better-fetch-rpc/issues"
15
+ },
16
+ "license": "MIT",
17
+ "author": "Nguyên (Wynn)",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/nbnguyen75/better-fetch-rpc.git"
21
+ },
22
+ "files": [
23
+ "dist"
24
+ ],
25
+ "type": "module",
26
+ "sideEffects": false,
27
+ "main": "./dist/index.cjs",
28
+ "module": "./dist/index.js",
29
+ "types": "./dist/index.d.ts",
30
+ "exports": {
31
+ ".": {
32
+ "types": "./dist/index.d.ts",
33
+ "import": "./dist/index.js",
34
+ "require": "./dist/index.cjs"
35
+ }
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "scripts": {
41
+ "build": "tsdown",
42
+ "typecheck": "tsc --noEmit",
43
+ "lint": "oxlint",
44
+ "lint:fix": "oxlint --fix",
45
+ "format": "oxfmt --check",
46
+ "format:fix": "oxfmt",
47
+ "test": "vitest run",
48
+ "release": "bumpp --commit --tag --push",
49
+ "prepublishOnly": "bun run test & bun run build",
50
+ "prepare": "husky"
51
+ },
52
+ "devDependencies": {
53
+ "@better-fetch/fetch": "^1.3.2",
54
+ "@changesets/cli": "^3.0.3",
55
+ "@types/bun": "^1.4.2",
56
+ "@types/node": "^26.6.2",
57
+ "bumpp": "^12.3.0",
58
+ "husky": "^9.1.7",
59
+ "lint-staged": "^17.6.0",
60
+ "oxfmt": "^0.70.0",
61
+ "oxlint": "^1.85.0",
62
+ "oxlint-tsgolint": "^7.0.2001",
63
+ "tsdown": "^0.23.0",
64
+ "typescript": "^5.9.3",
65
+ "valibot": "^1.1.0",
66
+ "vitest": "^3.2.4",
67
+ "zod": "^4.6.5"
68
+ },
69
+ "peerDependencies": {
70
+ "@better-fetch/fetch": "^1.1.21"
71
+ },
72
+ "lint-staged": {
73
+ "src/*": [
74
+ "oxlint --fix"
75
+ ]
76
+ }
77
+ }