better-call 1.1.3 → 1.1.5

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Better Call
2
2
 
3
- Better call is a tiny web framework for creating endpoints that can be invoked as a normal function or mounted to a router to be served by any web standard compatible server (like Bun, node, nextjs, sveltekit...) and also includes a typed RPC client for typesafe client-side invocation of these endpoints.
3
+ Better call is a tiny web framework for creating endpoints that can be invoked as a normal function or mounted to a router to be served by any web standard compatible server (like Bun, node, nextjs, sveltekit...) and also includes a typed RPC client for type-safe client-side invocation of these endpoints.
4
4
 
5
5
  Built for typescript and it comes with a very high performance router based on [rou3](https://github.com/unjs/rou3).
6
6
 
@@ -21,10 +21,11 @@ pnpm i zod
21
21
  The building blocks for better-call are endpoints. You can create an endpoint by calling `createEndpoint` and passing it a path, [options](#endpointoptions) and a handler that will be invoked when the endpoint is called.
22
22
 
23
23
  ```ts
24
- import { createEndpoint, createRouter } from "better-call"
24
+ // endpoint.ts
25
+ import { createEndpoint } from "better-call"
25
26
  import { z } from "zod"
26
27
 
27
- const createItem = createEndpoint("/item", {
28
+ export const createItem = createEndpoint("/item", {
28
29
  method: "POST",
29
30
  body: z.object({
30
31
  id: z.string()
@@ -43,6 +44,8 @@ const item = await createItem({
43
44
  id: "123"
44
45
  }
45
46
  })
47
+
48
+ console.log(item); // { item: { id: '123' } }
46
49
  ```
47
50
 
48
51
  OR you can mount the endpoint to a router and serve it with any web standard compatible server.
@@ -50,7 +53,11 @@ OR you can mount the endpoint to a router and serve it with any web standard com
50
53
  > The example below uses [Bun](https://bun.sh/)
51
54
 
52
55
  ```ts
53
- const router = createRouter({
56
+ // router.ts
57
+ import { createRouter } from "better-call"
58
+ import { createItem } from "./endpoint"
59
+
60
+ export const router = createRouter({
54
61
  createItem
55
62
  })
56
63
 
@@ -62,101 +69,21 @@ Bun.serve({
62
69
  Then you can use the rpc client to call the endpoints on client.
63
70
 
64
71
  ```ts
65
- //client.ts
72
+ // client.ts
66
73
  import type { router } from "./router" // import router type
67
- import { createClient } from "better-call/client";
74
+ import { createClient } from "better-call/client"
68
75
 
69
76
  const client = createClient<typeof router>({
70
77
  baseURL: "http://localhost:3000"
71
- });
72
- const items = await client("/item", {
73
- body: {
74
- id: "123"
75
- }
76
- });
77
- ```
78
-
79
- ### Returning non 200 responses
80
-
81
- There are several supported ways to a non 200 response:
82
-
83
- You can use the `ctx.setStatus(status)` helper to change the default status code of a successful response:
84
-
85
- ```ts
86
- const createItem = createEndpoint("/item", {
87
- method: "POST",
88
- body: z.object({
89
- id: z.string()
90
- })
91
- }, async (ctx) => {
92
- ctx.setStatus(201);
93
- return {
94
- item: {
95
- id: ctx.body.id
96
- }
97
- }
98
78
  })
99
- ```
100
79
 
101
- Sometimes, you want to respond with an error, in those cases you will need to throw Better Call's `APIError` error. If the endpoint is called as a function, the error will be thrown but if it's mounted to a router, the error will be converted to a response object with the correct status code and headers.
102
-
103
- ```ts
104
- const createItem = createEndpoint("/item", {
105
- method: "POST",
106
- body: z.object({
107
- id: z.string()
108
- })
109
- }, async (ctx) => {
110
- if(ctx.body.id === "123") {
111
- throw ctx.error("Bad Request", {
112
- message: "Id is not allowed"
113
- })
114
- }
115
- return {
116
- item: {
117
- id: ctx.body.id
118
- }
119
- }
120
- })
121
- ```
122
-
123
- You can also instead throw using a status code:
124
-
125
- ```ts
126
- const createItem = createEndpoint("/item", {
127
- method: "POST",
128
- body: z.object({
129
- id: z.string()
130
- })
131
- }, async (ctx) => {
132
- if(ctx.body.id === "123") {
133
- throw ctx.error(400, {
134
- message: "Id is not allowed"
135
- })
136
- }
137
- return {
138
- item: {
139
- id: ctx.body.id
140
- }
80
+ const item = await client("@post/item", {
81
+ body: {
82
+ id: "123"
141
83
  }
142
84
  })
143
- ```
144
85
 
145
- Finally, you can return a new `Response` object. In this case, the `ctx.setStatus()` call will be ignored, as the `Response` will have completely control over the final status code:
146
-
147
- ```ts
148
- const createItem = createEndpoint("/item", {
149
- method: "POST",
150
- body: z.object({
151
- id: z.string()
152
- })
153
- }, async (ctx) => {
154
- return Response.json({
155
- item: {
156
- id: ctx.body.id
157
- }
158
- }, { status: 201 });
159
- })
86
+ console.log(item) // { data: { item: { id: '123' } }, error: null }
160
87
  ```
161
88
 
162
89
  ### Endpoint
@@ -168,12 +95,12 @@ Endpoints are building blocks of better-call.
168
95
  The path is the URL path that the endpoint will respond to. It can be a direct path or a path with parameters and wildcards.
169
96
 
170
97
  ```ts
171
- //direct path
98
+ // direct path
172
99
  const endpoint = createEndpoint("/item", {
173
100
  method: "GET",
174
101
  }, async (ctx) => {})
175
102
 
176
- //path with parameters
103
+ // path with parameters
177
104
  const endpoint = createEndpoint("/item/:id", {
178
105
  method: "GET",
179
106
  }, async (ctx) => {
@@ -184,18 +111,18 @@ const endpoint = createEndpoint("/item/:id", {
184
111
  }
185
112
  })
186
113
 
187
- //path with wildcards
114
+ // path with wildcards
188
115
  const endpoint = createEndpoint("/item/**:name", {
189
116
  method: "GET",
190
117
  }, async (ctx) => {
191
- //the name will be the remaining path
118
+ // the name will be the remaining path
192
119
  ctx.params.name
193
120
  })
194
121
  ```
195
122
 
196
123
  #### Body Schema
197
124
 
198
- The `body` option accepts a standard schema and will validate the request body. If the request body doesn't match the schema, the endpoint will throw an error. If it's mounted to a router, it'll return a 400 error.
125
+ The `body` option accepts a standard schema and will validate the request body. If the request body doesn't match the schema, the endpoint will throw a validation error. If it's mounted to a router, it'll return a 400 error with the error details.
199
126
 
200
127
  ```ts
201
128
  const createItem = createEndpoint("/item", {
@@ -214,7 +141,7 @@ const createItem = createEndpoint("/item", {
214
141
 
215
142
  #### Query Schema
216
143
 
217
- The `query` option accepts a standard schema and will validate the request query. If the request query doesn't match the schema, the endpoint will throw an error. If it's mounted to a router, it'll return a 400 error.
144
+ The `query` option accepts a standard schema and will validate the request query. If the request query doesn't match the schema, the endpoint will throw a validation error. If it's mounted to a router, it'll return a 400 error with the error details.
218
145
 
219
146
  ```ts
220
147
  const createItem = createEndpoint("/item", {
@@ -231,12 +158,28 @@ const createItem = createEndpoint("/item", {
231
158
  })
232
159
  ```
233
160
 
161
+ #### Media types
162
+
163
+ By default, all media types are accepted, but only a handful of them have a built-in support:
164
+
165
+ - `application/json` and [custom json suffixes](https://datatracker.ietf.org/doc/html/rfc6839#section-3.1) are parsed as a JSON (plain) object
166
+ - `application/x-www-form-urlencoded` and `multipart/form-data` are parsed as a [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData) object
167
+ - `text/plain` is parsed as a plain string
168
+ - `application/octet-stream` is parsed as an [`ArrayBuffer`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer)
169
+ - `application/pdf`, `image/*` and `video/*` are parsed as [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob)
170
+ - `application/stream` is parsed as [`ReadableStream`](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream)
171
+ - Any other media type that is not recognized, will be parsed as a plain string.
172
+
173
+ Similarly, returning a supported type from a handler will properly serialize it with the correct `Content-Type` as specified above.
174
+
234
175
  #### Allowed Media Types
235
176
 
236
177
  You can restrict which media types (MIME types) are allowed for request bodies using the `allowedMediaTypes` option. This can be configured at both the router level and the endpoint level, with endpoint-level configuration taking precedence.
237
178
 
238
179
  When a request is made with a disallowed media type, the endpoint will return a `415 Unsupported Media Type` error.
239
180
 
181
+ > *Note*: Please note that using this option won't add parsing support for new media types, it only restricts the media types that are already supported.
182
+
240
183
  **Router-level configuration:**
241
184
 
242
185
  ```ts
@@ -309,13 +252,6 @@ const uploadFile = createEndpoint("/upload", {
309
252
  })
310
253
  ```
311
254
 
312
- Common media types:
313
- - `application/json` - JSON data
314
- - `application/x-www-form-urlencoded` - Form data
315
- - `multipart/form-data` - File uploads
316
- - `text/plain` - Plain text
317
- - `application/octet-stream` - Binary data
318
-
319
255
  > **Note:** The validation is case-insensitive and handles charset parameters automatically (e.g., `application/json; charset=utf-8` will match `application/json`).
320
256
 
321
257
  #### Require Headers
@@ -361,13 +297,176 @@ createItem({
361
297
 
362
298
  ### Handler
363
299
 
364
- this is the function that will be invoked when the endpoint is called. It accepts a context object that contains the request, headers, body, query, params and other information.
300
+ This is the function that will be invoked when the endpoint is called. The signature is:
301
+
302
+ ```ts
303
+ const handler = async (ctx) => response;
304
+ ```
305
+
306
+ Where `ctx` is:
307
+
308
+ - The context object containing the `request`, `headers`, `body`, `query`, `params` and a few helper functions. If there is a middleware, the context will be extended with the middleware context.
309
+
310
+ And `response` is any supported response type:
311
+
312
+ - a `Response` object
313
+ - any javascript value: `string`, `number`, `boolean`, an `object` or an `array`
314
+ - the return value of the `ctx.json()` helper
315
+
316
+ Below, we document all the ways in which you can create a response in your handler:
365
317
 
366
- It can return a response object, a string, a number, a boolean, an object or an array.
318
+ #### Returning a response
367
319
 
368
- It can also throw an error and if it throws APIError, it will be converted to a response object with the correct status code and headers.
320
+ You can use the `ctx.setStatus(status)` helper to change the default status code of a successful response:
321
+
322
+ ```ts
323
+ const createItem = createEndpoint("/item", {
324
+ method: "POST",
325
+ body: z.object({
326
+ id: z.string()
327
+ })
328
+ }, async (ctx) => {
329
+ ctx.setStatus(201);
330
+ return {
331
+ item: {
332
+ id: ctx.body.id
333
+ }
334
+ }
335
+ })
336
+ ```
337
+
338
+ Sometimes, you want to respond with an error, in those cases you will need to throw better-call's `APIError` error or use the `ctx.error()` helper, they both have the same signatures!
339
+ If the endpoint is called as a function, the error will be thrown but if it's mounted to a router, the error will be converted to a response object with the correct status code and headers.
340
+
341
+ ```ts
342
+ import { APIError } from "better-call"
343
+
344
+ const createItem = createEndpoint("/item", {
345
+ method: "POST",
346
+ body: z.object({
347
+ id: z.string()
348
+ })
349
+ }, async (ctx) => {
350
+ if (ctx.body.id === "123") {
351
+ throw ctx.error("BAD_REQUEST", {
352
+ message: "Id is not allowed"
353
+ })
354
+ }
355
+
356
+ if (ctx.body.id === "456") {
357
+ throw new APIError("BAD_REQUEST", {
358
+ message: "Id is not allowed"
359
+ })
360
+ }
361
+ return {
362
+ item: {
363
+ id: ctx.body.id
364
+ }
365
+ }
366
+ })
367
+ ```
368
+
369
+ You can also instead throw using a status code:
370
+
371
+ ```ts
372
+ const createItem = createEndpoint("/item", {
373
+ method: "POST",
374
+ body: z.object({
375
+ id: z.string()
376
+ })
377
+ }, async (ctx) => {
378
+ if (ctx.body.id === "123") {
379
+ throw ctx.error(400, {
380
+ message: "Id is not allowed"
381
+ })
382
+ }
383
+ return {
384
+ item: {
385
+ id: ctx.body.id
386
+ }
387
+ }
388
+ })
389
+ ```
390
+
391
+ You can also specify custom response headers:
392
+
393
+ ```ts
394
+ const createItem = createEndpoint("/item", {
395
+ method: "POST",
396
+ body: z.object({
397
+ id: z.string()
398
+ })
399
+ }, async (ctx) => {
400
+ if (ctx.body.id === "123") {
401
+ throw ctx.error(
402
+ 400,
403
+ { message: "Id is not allowed" },
404
+ { "x-key": "value" } // custom response headers
405
+ );
406
+ }
407
+ return {
408
+ item: {
409
+ id: ctx.body.id
410
+ }
411
+ }
412
+ })
413
+ ```
414
+
415
+ Or create a redirection:
416
+
417
+ ```ts
418
+ const createItem = createEndpoint("/item", {
419
+ method: "POST",
420
+ body: z.object({
421
+ id: z.string()
422
+ })
423
+ }, async (ctx) => {
424
+ if (ctx.body.id === "123") {
425
+ throw ctx.redirect("/item/123");
426
+ }
427
+ return {
428
+ item: {
429
+ id: ctx.body.id
430
+ }
431
+ }
432
+ })
433
+ ```
434
+
435
+ Or use the `ctx.json()` to return any object:
436
+
437
+ ```ts
438
+ const createItem = createEndpoint("/item", {
439
+ method: "POST",
440
+ body: z.object({
441
+ id: z.string()
442
+ })
443
+ }, async (ctx) => {
444
+ return ctx.json({
445
+ item: {
446
+ id: ctx.body.id
447
+ }
448
+ })
449
+ })
450
+ ```
451
+
452
+ Finally, you can return a new `Response` object. In this case, the `ctx.setStatus()` call will be ignored, as the `Response` will have completely control over the final status code:
453
+
454
+ ```ts
455
+ const createItem = createEndpoint("/item", {
456
+ method: "POST",
457
+ body: z.object({
458
+ id: z.string()
459
+ })
460
+ }, async (ctx) => {
461
+ return Response.json({
462
+ item: {
463
+ id: ctx.body.id
464
+ }
465
+ }, { status: 201 });
466
+ })
467
+ ```
369
468
 
370
- - **Context**: the context object contains the request, headers, body, query, params and a helper function to set headers, cookies and get cookies. If there is a middleware, the context will be extended with the middleware context.
469
+ > **Note**: Please note that when using the `Response` API, your endpoint will not return the JSON object even if you use the `Response.json()` helper, you'll always get a `Response` as a result.
371
470
 
372
471
  ### Middleware
373
472
 
@@ -388,7 +487,7 @@ const endpoint = createEndpoint("/", {
388
487
  method: "GET",
389
488
  use: [middleware],
390
489
  }, async (ctx) => {
391
- //this will be the context object returned by the middleware with the name property
490
+ // this will be the context object returned by the middleware with the name property
392
491
  ctx.context
393
492
  })
394
493
  ```
@@ -577,7 +676,7 @@ const items = await client("/item", {
577
676
 
578
677
  ### Headers and Cookies
579
678
 
580
- If you return a response object from an endpoint, the headers and cookies will be set on the response object. But You can set headers and cookies for the context object.
679
+ If you return a response object from an endpoint, the headers and cookies will be set on the response object. But You can set headers and cookies for the context object.
581
680
 
582
681
  ```ts
583
682
  const createItem = createEndpoint("/item", {
@@ -614,7 +713,7 @@ const createItem = createEndpoint("/item", {
614
713
  })
615
714
  ```
616
715
 
617
- > other than normal cookies the ctx object also exposes signed cookies.
716
+ > **Note**: The context object also exposes and allows you to interact with signed cookies via the `ctx.getSignedCookie()` and `ctx.setSignedCookie()` helpers.
618
717
 
619
718
  ### Endpoint Creator
620
719
 
package/dist/client.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { et as HasRequiredKeys, i as Endpoint, st as UnionToIntersection, t as Router } from "./router.cjs";
1
+ import { ct as UnionToIntersection, i as Endpoint, t as Router, tt as HasRequiredKeys } from "./router.cjs";
2
2
  import { BetterFetchOption, BetterFetchResponse } from "@better-fetch/fetch";
3
3
 
4
4
  //#region src/client.d.ts
package/dist/client.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { et as HasRequiredKeys, i as Endpoint, st as UnionToIntersection, t as Router } from "./router.js";
1
+ import { ct as UnionToIntersection, i as Endpoint, t as Router, tt as HasRequiredKeys } from "./router.js";
2
2
  import { BetterFetchOption, BetterFetchResponse } from "@better-fetch/fetch";
3
3
 
4
4
  //#region src/client.d.ts
package/dist/index.cjs CHANGED
@@ -116,6 +116,17 @@ var InternalAPIError = class extends Error {
116
116
  } : void 0;
117
117
  }
118
118
  };
119
+ var ValidationError = class extends InternalAPIError {
120
+ constructor(message, issues) {
121
+ super(400, {
122
+ message,
123
+ code: "VALIDATION_ERROR"
124
+ });
125
+ this.message = message;
126
+ this.issues = issues;
127
+ this.issues = issues;
128
+ }
129
+ };
119
130
  var BetterCallError = class extends Error {
120
131
  constructor(message) {
121
132
  super(message);
@@ -180,6 +191,19 @@ function tryDecode(str) {
180
191
  return str;
181
192
  }
182
193
  }
194
+ async function tryCatch(promise) {
195
+ try {
196
+ return {
197
+ data: await promise,
198
+ error: null
199
+ };
200
+ } catch (error) {
201
+ return {
202
+ data: null,
203
+ error
204
+ };
205
+ }
206
+ }
183
207
 
184
208
  //#endregion
185
209
  //#region src/to-response.ts
@@ -301,11 +325,17 @@ async function runValidation(options, context = {}) {
301
325
  }
302
326
  if (options.requireHeaders && !context.headers) return {
303
327
  data: null,
304
- error: { message: "Headers is required" }
328
+ error: {
329
+ message: "Headers is required",
330
+ issues: []
331
+ }
305
332
  };
306
333
  if (options.requireRequest && !context.request) return {
307
334
  data: null,
308
- error: { message: "Request is required" }
335
+ error: {
336
+ message: "Request is required",
337
+ issues: []
338
+ }
309
339
  };
310
340
  return {
311
341
  data: request,
@@ -313,12 +343,12 @@ async function runValidation(options, context = {}) {
313
343
  };
314
344
  }
315
345
  function fromError(error, validating) {
316
- const errorMessages = [];
317
- for (const issue of error) {
318
- const message = issue.message;
319
- errorMessages.push(message);
320
- }
321
- return { message: `Invalid ${validating} parameters` };
346
+ return {
347
+ message: error.map((e) => {
348
+ return `[${e.path?.length ? `${validating}.` + e.path.map((x) => typeof x === "object" ? x.key : x).join(".") : validating}] ${e.message}`;
349
+ }).join("; "),
350
+ issues: error
351
+ };
322
352
  }
323
353
 
324
354
  //#endregion
@@ -438,10 +468,7 @@ const createInternalContext = async (context, { options, path }) => {
438
468
  const headers = new Headers();
439
469
  let responseStatus = void 0;
440
470
  const { data, error } = await runValidation(options, context);
441
- if (error) throw new APIError(400, {
442
- message: error.message,
443
- code: "VALIDATION_ERROR"
444
- });
471
+ if (error) throw new ValidationError(error.message, error.issues);
445
472
  const requestHeaders = "headers" in context ? context.headers instanceof Headers ? context.headers : new Headers(context.headers) : "request" in context && context.request instanceof Request ? context.request.headers : null;
446
473
  const requestCookies = requestHeaders?.get("cookie");
447
474
  const parsedCookies = requestCookies ? parseCookies(requestCookies) : void 0;
@@ -537,12 +564,24 @@ function createEndpoint(pathOrOptions, handlerOrOptions, handlerOrNever) {
537
564
  const options = typeof handlerOrOptions === "object" ? handlerOrOptions : pathOrOptions;
538
565
  const handler = typeof handlerOrOptions === "function" ? handlerOrOptions : handlerOrNever;
539
566
  if ((options.method === "GET" || options.method === "HEAD") && options.body) throw new BetterCallError("Body is not allowed with GET or HEAD methods");
567
+ if (path && /\/{2,}/.test(path)) throw new BetterCallError("Path cannot contain consecutive slashes");
540
568
  const internalHandler = async (...inputCtx) => {
541
569
  const context = inputCtx[0] || {};
542
- const internalContext = await createInternalContext(context, {
570
+ const { data: internalContext, error: validationError } = await tryCatch(createInternalContext(context, {
543
571
  options,
544
572
  path
545
- });
573
+ }));
574
+ if (validationError) {
575
+ if (!(validationError instanceof ValidationError)) throw validationError;
576
+ if (options.onValidationError) await options.onValidationError({
577
+ message: validationError.message,
578
+ issues: validationError.issues
579
+ });
580
+ throw new APIError(400, {
581
+ message: validationError.message,
582
+ code: "VALIDATION_ERROR"
583
+ });
584
+ }
546
585
  const response = await handler(internalContext).catch(async (e) => {
547
586
  if (isAPIError(e)) {
548
587
  const onAPIError = options.onAPIError;
@@ -824,7 +863,8 @@ const createRouter = (endpoints, config) => {
824
863
  if (config?.routerMiddleware?.length) for (const { path, middleware } of config.routerMiddleware) (0, rou3.addRoute)(middlewareRouter, "*", path, middleware);
825
864
  const processRequest = async (request) => {
826
865
  const url = new URL(request.url);
827
- const path = config?.basePath ? url.pathname.split(config.basePath).reduce((acc, curr, index) => {
866
+ const pathname = url.pathname;
867
+ const path = config?.basePath && config.basePath !== "/" ? pathname.split(config.basePath).reduce((acc, curr, index) => {
828
868
  if (index !== 0) if (index > 1) acc.push(`${config.basePath}${curr}`);
829
869
  else acc.push(curr);
830
870
  return acc;
@@ -833,7 +873,15 @@ const createRouter = (endpoints, config) => {
833
873
  status: 404,
834
874
  statusText: "Not Found"
835
875
  });
876
+ if (/\/{2,}/.test(path)) return new Response(null, {
877
+ status: 404,
878
+ statusText: "Not Found"
879
+ });
836
880
  const route = (0, rou3.findRoute)(router, request.method, path);
881
+ if (path.endsWith("/") !== route?.data?.path?.endsWith("/") && !config?.skipTrailingSlashes) return new Response(null, {
882
+ status: 404,
883
+ statusText: "Not Found"
884
+ });
837
885
  if (!route?.data) return new Response(null, {
838
886
  status: 404,
839
887
  statusText: "Not Found"
@@ -902,6 +950,7 @@ const createRouter = (endpoints, config) => {
902
950
  //#endregion
903
951
  exports.APIError = APIError;
904
952
  exports.BetterCallError = BetterCallError;
953
+ exports.ValidationError = ValidationError;
905
954
  exports.createEndpoint = createEndpoint;
906
955
  exports.createInternalContext = createInternalContext;
907
956
  exports.createMiddleware = createMiddleware;