@aklinker1/zeta 1.3.2 โ 2.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/README.md +21 -613
- package/package.json +8 -7
- package/src/adapters/zod-schema-adapter.ts +1 -18
- package/src/app.ts +63 -115
- package/src/client.ts +5 -4
- package/src/errors.ts +0 -14
- package/src/internal/compile-fetch-function.ts +157 -0
- package/src/internal/compile-route-handler.ts +190 -0
- package/src/internal/context.ts +47 -0
- package/src/internal/serialization.ts +30 -31
- package/src/internal/utils.ts +77 -46
- package/src/open-api.ts +33 -18
- package/src/schema.ts +2 -2
- package/src/types.ts +22 -24
- package/src/internal/call-handler.ts +0 -139
package/README.md
CHANGED
|
@@ -1,637 +1,45 @@
|
|
|
1
1
|
# Zeta
|
|
2
2
|
|
|
3
|
-
[](https://jsr.io/@aklinker1/zeta) [](https://www.npmjs.com/package/@aklinker1/zeta) [](https://
|
|
3
|
+
[](https://jsr.io/@aklinker1/zeta) [](https://www.npmjs.com/package/@aklinker1/zeta) [](https://zeta.aklinker1.io) [](https://jsr.io/@aklinker1/zeta/doc) [](https://github.com/aklinker1/zeta/blob/main/LICENSE) [](https://github.com/aklinker1/zeta/blob/main/CHANGELOG.md)
|
|
4
4
|
|
|
5
|
-
Composable, testable, OpenAPI-first backend framework with validation built-in.
|
|
6
|
-
|
|
7
|
-
**Features**
|
|
8
|
-
|
|
9
|
-
- โ
[Standard schema](https://standardschema.dev/) support (Zod, Arktype, Valibot, etc)
|
|
10
|
-
- ๐งฉ Composable apps, plugins, and routes
|
|
11
|
-
- ๐ค Type-safe server and client side code
|
|
12
|
-
- โ๏ธ WinterCG compatible
|
|
13
|
-
- ๐งช Easy to test
|
|
14
|
-
- ๐ OpenAPI docs built-in
|
|
15
|
-
|
|
16
|
-
## Quick Start
|
|
17
|
-
|
|
18
|
-
Create a file `index.ts` and add the following code:
|
|
19
|
-
|
|
20
|
-
```ts
|
|
21
|
-
import { createApp } from "@aklinker1/zeta";
|
|
22
|
-
|
|
23
|
-
const app = createApp().get("/", {}, () => {
|
|
24
|
-
return { message: "Hello World!" };
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
app.listen(3000);
|
|
28
|
-
|
|
29
|
-
console.log("Server running at http://localhost:3000");
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
Run the app with Bun or Deno:
|
|
33
|
-
|
|
34
|
-
```sh
|
|
35
|
-
# With Bun
|
|
36
|
-
bun run index.ts
|
|
37
|
-
|
|
38
|
-
# With Deno
|
|
39
|
-
deno run --allow-net index.ts
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
Now, if you visit <http://localhost:3000> in your browser or with `curl`, you will see `{"message":"Hello World!"}`.
|
|
43
|
-
|
|
44
|
-
## `createApp`
|
|
45
|
-
|
|
46
|
-
Use `createApp` to create an app instance.
|
|
47
|
-
|
|
48
|
-
```ts
|
|
49
|
-
import { createApp } from "@aklinker1/zeta";
|
|
50
|
-
|
|
51
|
-
const app = createApp(/* options */);
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
**Options:**
|
|
55
|
-
|
|
56
|
-
- **`prefix`**: The base path all endpoints defined on the app will be prefixed with.
|
|
57
|
-
- _Default: `""`_
|
|
58
|
-
- **`origin`** (top-level app only): The origin used when constructing `URL` instances alongside the request path (`new URL(request.path, origin)`).
|
|
59
|
-
- _Default: `"http://localhost"`_
|
|
60
|
-
- **`schemaAdapter`** (top-level app only): The schema adapter to use when creating the OpenAPI spec.
|
|
61
|
-
- Zeta provides an adapter for [Zod](https://zod.dev/).
|
|
62
|
-
- **`openApi`** (top-level app only): Base OpenAPI spec.
|
|
63
|
-
- **`openApiRoute`** (top-level app only): Where the OpenAPI spec will be served from.
|
|
64
|
-
- _Default: `"/openapi.json"`_
|
|
65
|
-
- **`scalar`** (top-level app only): Config for the [Scalar API Reference](https://guides.scalar.com/scalar/scalar-api-references/configuration)
|
|
66
|
-
- **`scalarRoute`** (top-level app only): Where the Scalar API Reference will be served from.
|
|
67
|
-
- _Default: `"/scalar"`_
|
|
68
|
-
|
|
69
|
-
Zeta's design revolves around composing multiple app instances. The main app, which you call `.listen()` or `.build()` on, is considered the **top-level app**. Any app instance that you pass into another app's `.use()` method is considered a **child app**. Certain options, like OpenAPI configuration, only make sense on the top-level app.
|
|
5
|
+
Composable, fast, testable, OpenAPI-first backend framework with validation built-in.
|
|
70
6
|
|
|
71
7
|
```ts
|
|
8
|
+
// main.ts
|
|
72
9
|
import { createApp } from "@aklinker1/zeta";
|
|
73
10
|
import { zodSchemaAdapter } from "@aklinker1/zeta/adapters/zod-schema-adapter";
|
|
74
|
-
|
|
75
|
-
const apiApp = createApp({ prefix: "/api" });
|
|
76
|
-
|
|
77
|
-
const app = createApp({
|
|
78
|
-
schemaAdapter: zodSchemaAdapter,
|
|
79
|
-
openApi: {
|
|
80
|
-
info: {
|
|
81
|
-
title: "Example App",
|
|
82
|
-
version: "1.0.0",
|
|
83
|
-
},
|
|
84
|
-
},
|
|
85
|
-
}).use(apiApp);
|
|
86
|
-
```
|
|
87
|
-
|
|
88
|
-
For more details about composing app instances together, see [`App#use`](#appuse).
|
|
89
|
-
|
|
90
|
-
## `App#listen`
|
|
91
|
-
|
|
92
|
-
> [!WARNING]
|
|
93
|
-
> The `listen` method only works in the Bun and Deno runtimes. To serve your app in a different runtime, use [`build`](#appbuild) instead.
|
|
94
|
-
|
|
95
|
-
To serve an app on a port, use the `listen` method:
|
|
96
|
-
|
|
97
|
-
```ts
|
|
98
|
-
import { createApp } from "@aklinker1/zeta";
|
|
99
|
-
|
|
100
|
-
const app = createApp();
|
|
101
|
-
|
|
102
|
-
app.listen(3000);
|
|
103
|
-
```
|
|
104
|
-
|
|
105
|
-
You can then make requests to it:
|
|
106
|
-
|
|
107
|
-
```ts
|
|
108
|
-
-> GET http://localhost:3000/some-endpoint
|
|
109
|
-
<- 404 Not Found
|
|
110
|
-
```
|
|
111
|
-
|
|
112
|
-
## Defining Routes
|
|
113
|
-
|
|
114
|
-
You can add a route to your app using any of the following methods:
|
|
115
|
-
|
|
116
|
-
- `App#get`: Add a `GET` route handler.
|
|
117
|
-
- `App#post`: Add a `POST` route handler.
|
|
118
|
-
- `App#put`: Add a `PUT` route handler.
|
|
119
|
-
- `App#delete`: Add a `DELETE` route handler.
|
|
120
|
-
- `App#method`: Add a route handler for a custom method.
|
|
121
|
-
- `App#any`: Add a route handler for any method at a given path.
|
|
122
|
-
|
|
123
|
-
```ts
|
|
124
|
-
const app = createApp()
|
|
125
|
-
.get("/api/users", {}, async (ctx) => {
|
|
126
|
-
return [
|
|
127
|
-
// ...
|
|
128
|
-
];
|
|
129
|
-
})
|
|
130
|
-
.post("/api/users", {}, async (ctx) => {
|
|
131
|
-
// ...
|
|
132
|
-
})
|
|
133
|
-
.put("/api/users/:id", {}, async (ctx) => {
|
|
134
|
-
// ...
|
|
135
|
-
})
|
|
136
|
-
.delete("/api/users/:id", {}, async (ctx) => {
|
|
137
|
-
// ...
|
|
138
|
-
})
|
|
139
|
-
.method("PATCH", "/api/users/:id", {}, async (ctx) => {
|
|
140
|
-
// ...
|
|
141
|
-
})
|
|
142
|
-
.any("/api/users", {}, async (ctx) => {
|
|
143
|
-
// ...
|
|
144
|
-
});
|
|
145
|
-
```
|
|
146
|
-
|
|
147
|
-
**Arguments:**
|
|
148
|
-
|
|
149
|
-
1. `route`: The path to match against.
|
|
150
|
-
2. `definition`: Define parameters and OpenAPI docs about the route.
|
|
151
|
-
3. `handler`: The callback function executed when a matching request is received.
|
|
152
|
-
|
|
153
|
-
### Path Parameters
|
|
154
|
-
|
|
155
|
-
Internally, Zeta uses [`rou3`](https://www.npmjs.com/package/rou3) to match routes. To add a path parameter, you can use `:name`, `**`, or `**:name`. For type safety, you can use a validation framework to define an object schema for the path parameters.
|
|
156
|
-
|
|
157
|
-
> Note that path parameters are strings. If your validation framework supports converting strings to other types, like with Zod's [`z.coerce`](https://zod.dev/api?id=coercion) or [`z.stringbool`](https://zod.dev/api?id=stringbool), you can use it to convert the string values to the desired type.
|
|
158
|
-
|
|
159
|
-
### Query Parameters
|
|
160
|
-
|
|
161
|
-
```ts
|
|
162
11
|
import { z } from "zod";
|
|
163
12
|
|
|
164
|
-
const app = createApp().get(
|
|
165
|
-
"/users",
|
|
166
|
-
{
|
|
167
|
-
query: z.object({
|
|
168
|
-
search: z.string(),
|
|
169
|
-
sortBy: z.enum(["username", "createdAt"]).default("username"),
|
|
170
|
-
sortDirection: z.enum(["asc", "desc"]).default("asc"),
|
|
171
|
-
page: z.coerce.number().int().min(1).default(1),
|
|
172
|
-
pageSize: z.coerce.number().int().min(1).max(100).default(10),
|
|
173
|
-
includeProfile: z.stringbool().default(false),
|
|
174
|
-
}),
|
|
175
|
-
},
|
|
176
|
-
({ query }) => {
|
|
177
|
-
console.log(query);
|
|
178
|
-
// {
|
|
179
|
-
// search: '...',
|
|
180
|
-
// sortBy: 'username',
|
|
181
|
-
// sortDirection: 'asc',
|
|
182
|
-
// page: 1,
|
|
183
|
-
// pageSize: 10
|
|
184
|
-
// }
|
|
185
|
-
},
|
|
186
|
-
);
|
|
187
|
-
```
|
|
188
|
-
|
|
189
|
-
### Body
|
|
190
|
-
|
|
191
|
-
```ts
|
|
192
|
-
import { z } from "zod";
|
|
193
|
-
|
|
194
|
-
const app = createApp().post(
|
|
195
|
-
"/users",
|
|
196
|
-
{
|
|
197
|
-
body: z.object({
|
|
198
|
-
username: z.string(),
|
|
199
|
-
email: z.string(),
|
|
200
|
-
}),
|
|
201
|
-
},
|
|
202
|
-
({ body }) => {
|
|
203
|
-
console.log(body);
|
|
204
|
-
},
|
|
205
|
-
);
|
|
206
|
-
```
|
|
207
|
-
|
|
208
|
-
### Response
|
|
209
|
-
|
|
210
|
-
You can either define a single response or multiple responses.
|
|
211
|
-
|
|
212
|
-
For single response schemas, a `200 OK` status code is assumed.
|
|
213
|
-
|
|
214
|
-
```ts
|
|
215
|
-
import { z } from "zod";
|
|
216
|
-
|
|
217
|
-
const app = createApp().get(
|
|
218
|
-
"/api/health",
|
|
219
|
-
{
|
|
220
|
-
// Note: The property key is always responses (plural), even when defining a
|
|
221
|
-
// single response schema.
|
|
222
|
-
responses: z.object({
|
|
223
|
-
status: z.literal("up"),
|
|
224
|
-
version: z.string(),
|
|
225
|
-
}),
|
|
226
|
-
},
|
|
227
|
-
() => ({ status: "up", version: "..." }),
|
|
228
|
-
);
|
|
229
|
-
```
|
|
230
|
-
|
|
231
|
-
When defining multiple schemas for different status codes, instead of returning the value directly, you'll need to return the value of the `status` function:
|
|
232
|
-
|
|
233
|
-
```ts
|
|
234
|
-
import { ErrorResponse, createApp, HttpStatus } from "@aklinker1/zeta";
|
|
235
|
-
import { NotFoundHttpError } from "@aklinker1/zeta/errors";
|
|
236
|
-
|
|
237
|
-
const app = createApp().post(
|
|
238
|
-
"/api/users",
|
|
239
|
-
{
|
|
240
|
-
body: UserSchema,
|
|
241
|
-
responses: {
|
|
242
|
-
[HttpStatus.Created]: User,
|
|
243
|
-
[HttpStatus.Conflict]: ErrorResponse,
|
|
244
|
-
},
|
|
245
|
-
},
|
|
246
|
-
async ({ status, body }) => {
|
|
247
|
-
const userExists = await db.doesUserExist(body.email);
|
|
248
|
-
|
|
249
|
-
// For error responses, throwing is the recommended approach.
|
|
250
|
-
// Zeta maps the HttpError's status code to the correct response schema.
|
|
251
|
-
if (userExists) {
|
|
252
|
-
throw new HttpError(
|
|
253
|
-
HttpStatus.Conflict,
|
|
254
|
-
"A user with this email already exists.",
|
|
255
|
-
);
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
const newUser = await db.createUser(body);
|
|
259
|
-
|
|
260
|
-
return status(HttpStatus.Created, newUser);
|
|
261
|
-
},
|
|
262
|
-
);
|
|
263
|
-
```
|
|
264
|
-
|
|
265
|
-
> When defining custom error responses, use `ErrorResponse` schema from `@aklinker1/zeta`.
|
|
266
|
-
|
|
267
|
-
When a response schema(s) are defined, the return value from the function is type-safe.
|
|
268
|
-
|
|
269
|
-
#### Custom Content Types
|
|
270
|
-
|
|
271
|
-
By default, Zeta will use `application/json` as the content type in the OpenAPI docs and infer the content type of the response at runtime based on the response type.
|
|
272
|
-
|
|
273
|
-
You can override both the docs and the response content type by setting the `contentType` metadata on your schema:
|
|
274
|
-
|
|
275
|
-
```ts
|
|
276
|
-
app.get(
|
|
277
|
-
"/csv",
|
|
278
|
-
{
|
|
279
|
-
responses: z.string().meta({ contentType: "text/csv" }),
|
|
280
|
-
},
|
|
281
|
-
() => {
|
|
282
|
-
// ...
|
|
283
|
-
},
|
|
284
|
-
);
|
|
285
|
-
```
|
|
286
|
-
|
|
287
|
-
## Life Cycle Hooks
|
|
288
|
-
|
|
289
|
-
## `App#decorate`
|
|
290
|
-
|
|
291
|
-
Shorthand for `.onTransform(() => decorators)`, just adding values to the request context.
|
|
292
|
-
|
|
293
|
-
```ts
|
|
294
|
-
const db = ...;
|
|
295
|
-
const redis = ...;
|
|
296
|
-
|
|
297
|
-
const app = createApp()
|
|
298
|
-
.decorate("db", db)
|
|
299
|
-
.decorate("redis", redis)
|
|
300
|
-
// OR in a single call
|
|
301
|
-
.decorate({ db, redis })
|
|
302
|
-
|
|
303
|
-
// Then you can access the decorated values in the handler
|
|
304
|
-
.get("/path", {}, ({ db, redis }) => {
|
|
305
|
-
// ...
|
|
306
|
-
})
|
|
307
|
-
```
|
|
308
|
-
|
|
309
|
-
## `App#mount`
|
|
310
|
-
|
|
311
|
-
You can add another server-side `fetch` function to the app using the `mount` function:
|
|
312
|
-
|
|
313
|
-
```ts
|
|
314
|
-
const app = createApp().mount((request: Request) => new Response());
|
|
315
|
-
```
|
|
316
|
-
|
|
317
|
-
If no other route defined on the app is matched, the mounted `fetch` function will be called instead.
|
|
318
|
-
|
|
319
|
-
The mount function is useful for adding another framekwork to your app. My main use-case for `mount` is using [`@aklinker1/aframe`'s `fetchStatic` method](https://github.com/aklinker1/aframe) to serve static files.
|
|
320
|
-
|
|
321
|
-
```ts
|
|
322
|
-
import { fetchStatic } from "@aklinker1/aframe/server";
|
|
323
|
-
|
|
324
|
-
const app = createApp().use(apiApp).mount(fetchStatic());
|
|
325
|
-
```
|
|
326
|
-
|
|
327
|
-
## `App#build`
|
|
328
|
-
|
|
329
|
-
Zeta is WinterCG compatible, meaning it takes in a [`Request` object](https://developer.mozilla.org/en-US/docs/Web/API/Request) and returns a [`Response` object](https://developer.mozilla.org/en-US/docs/Web/API/Response), similar to client-side `fetch` API.
|
|
330
|
-
|
|
331
|
-
To get this "fetch" function, call the `build` method:
|
|
332
|
-
|
|
333
|
-
```ts
|
|
334
|
-
const app = createApp();
|
|
335
|
-
|
|
336
|
-
const fetch = app.build(); // (request: Request) => MaybePromise<Response>
|
|
337
|
-
```
|
|
338
|
-
|
|
339
|
-
This makes it super easy to test or write scripts for your app without actually serving it over a port:
|
|
340
|
-
|
|
341
|
-
```ts
|
|
342
|
-
import myApp from "./my-app";
|
|
343
|
-
|
|
344
|
-
const fetch = myApp.build();
|
|
345
|
-
|
|
346
|
-
const request = new Request("http://localhost/some-endpoint");
|
|
347
|
-
|
|
348
|
-
const response = await fetch(request);
|
|
349
|
-
console.log(await response.text());
|
|
350
|
-
```
|
|
351
|
-
|
|
352
|
-
> There are additional ways of testing your app with type-safety, see [`createTestClient`](#createtestclient). But remember that you can always manually call the app's `fetch` function as shown above.
|
|
353
|
-
|
|
354
|
-
Additionally, you can use the `build` method to serve the app in whatever way you want, in case the built-in [`listen`](#applisten) method doesn't work for you:
|
|
355
|
-
|
|
356
|
-
```ts
|
|
357
|
-
Deno.serve(app.build());
|
|
358
|
-
```
|
|
359
|
-
|
|
360
|
-
## OpenAPI and Validation
|
|
361
|
-
|
|
362
|
-
Zeta supports any validation library that implements the ["Standard Schema" spec](https://standardschema.dev/#what-schema-libraries-implement-the-spec). However, the spec does not include standards for creating JSON schemas, required to generate OpenAPI specs.
|
|
363
|
-
|
|
364
|
-
So for Zeta to properly generate OpenAPI specs, you need to pass in a `schemaAdapter` to the top-level app instance.
|
|
365
|
-
|
|
366
|
-
```ts
|
|
367
|
-
import { createApp } from "@aklinker1/zeta";
|
|
368
|
-
import { zodSchemaAdapter } from "@aklinker1/zeta/adapters/zod-schema-adapter";
|
|
369
|
-
|
|
370
13
|
const app = createApp({
|
|
371
14
|
schemaAdapter: zodSchemaAdapter,
|
|
372
15
|
}).get(
|
|
373
|
-
"/
|
|
16
|
+
"/hello",
|
|
374
17
|
{
|
|
375
|
-
|
|
376
|
-
summary: "
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
operationId: "getHealth",
|
|
380
|
-
// ...
|
|
381
|
-
},
|
|
382
|
-
() => {
|
|
383
|
-
// ...
|
|
18
|
+
operationId: "sayHello",
|
|
19
|
+
summary: "Say Hello",
|
|
20
|
+
description: "A simple hello world example",
|
|
21
|
+
response: z.object({ message: z.string() }),
|
|
384
22
|
},
|
|
23
|
+
() => ({ message: "Hello world!" }),
|
|
385
24
|
);
|
|
386
25
|
|
|
387
26
|
app.listen(3000);
|
|
388
27
|
```
|
|
389
28
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
By default, Zeta will not put any models in `components.schemas` nor add `$ref` for those models.
|
|
395
|
-
|
|
396
|
-
You are in charge of determining which models should be added to `components.schemas` by adding a `ref` meta to the model's schema:
|
|
397
|
-
|
|
398
|
-
```ts
|
|
399
|
-
import { z } from "zod";
|
|
400
|
-
|
|
401
|
-
const User = z
|
|
402
|
-
.object({
|
|
403
|
-
id: z.string().uuid(),
|
|
404
|
-
email: z.string().email(),
|
|
405
|
-
})
|
|
406
|
-
.meta({
|
|
407
|
-
ref: "User",
|
|
408
|
-
});
|
|
409
|
-
```
|
|
410
|
-
|
|
411
|
-
When building your app's spec, Zeta will find these `ref` properties and move the object schemas into `components.schemas` automatically for you.
|
|
412
|
-
|
|
413
|
-
### Getting the OpenAPI Spec
|
|
414
|
-
|
|
415
|
-
You can get the OpenAPI spec by calling `app.getOpenApiSpec()`. You do not need to listen to any ports or fetch the `/openapi.json` endpoint. It's a simple function call away.
|
|
416
|
-
|
|
417
|
-
```ts
|
|
418
|
-
const app = createApp(...)
|
|
419
|
-
|
|
420
|
-
app.getOpenApiSpec() // { version: "3.1", info: { ... }, paths: { ... }, ... }
|
|
421
|
-
```
|
|
422
|
-
|
|
423
|
-
## Composing Multiple Apps
|
|
424
|
-
|
|
425
|
-
By default, an app's context (hooks, decorators) is isolated. To make a child app's context available to its parent, you must explicitly chain `.export()` at the end of its definition. This effectively merges its isolated lifecycle hooks into the parent's.
|
|
426
|
-
|
|
427
|
-
For example. If a child app decorates the context with a database connection, the parent app does not have access to it by default. You will get a type error if you try to access the `db` property from the parent app.
|
|
428
|
-
|
|
429
|
-
```ts
|
|
430
|
-
const childApp = createApp()
|
|
431
|
-
.decorate({ db })
|
|
432
|
-
.get("/child-path", {}, ({ db }) => {
|
|
433
|
-
// โ
`db` is defined
|
|
434
|
-
db.query(...)
|
|
435
|
-
});
|
|
436
|
-
|
|
437
|
-
const parentApp = createApp()
|
|
438
|
-
.use(childApp)
|
|
439
|
-
.get("/parent-path", {}, ({ db }) => {
|
|
440
|
-
// ^^ Type Error: Property "db" does not exist
|
|
441
|
-
})
|
|
442
|
-
```
|
|
443
|
-
|
|
444
|
-
However, after adding `.export()` to the child app, the parent app will have access to the `db` property.
|
|
445
|
-
|
|
446
|
-
```diff
|
|
447
|
-
const childApp = createApp()
|
|
448
|
-
.decorate({ db })
|
|
449
|
-
.get("/child-path", {}, ({ db }) => {
|
|
450
|
-
db.query(...)
|
|
451
|
-
})
|
|
452
|
-
+ .export();
|
|
453
|
-
|
|
454
|
-
const parentApp = createApp()
|
|
455
|
-
.use(childApp)
|
|
456
|
-
.get("/parent-path", {}, ({ db }) => {
|
|
457
|
-
+ // โ
`db` is defined
|
|
458
|
-
+ db.query(...)
|
|
459
|
-
})
|
|
460
|
-
```
|
|
461
|
-
|
|
462
|
-
The recommended approach for composing multiple apps is to create a set of "plugins", child apps containing shared logic required by multiple different apps.
|
|
463
|
-
|
|
464
|
-
```ts
|
|
465
|
-
// plugins/context-plugin.ts
|
|
466
|
-
export const contextPlugin = createApp().decorate({ db, version }).export();
|
|
467
|
-
```
|
|
468
|
-
|
|
469
|
-
```ts
|
|
470
|
-
// routes/users.ts
|
|
471
|
-
import { contextPlugin } from "../plugins/context-plugin.ts";
|
|
472
|
-
|
|
473
|
-
export const usersPlugin = createApp({ prefix: "/users" })
|
|
474
|
-
.use(contextPlugin)
|
|
475
|
-
.get("/", {}, ({ db }) => {
|
|
476
|
-
// ...
|
|
477
|
-
});
|
|
478
|
-
```
|
|
479
|
-
|
|
480
|
-
```ts
|
|
481
|
-
// routes/api.ts
|
|
482
|
-
import { contextPlugin } from "../plugins/context-plugin.ts";
|
|
483
|
-
|
|
484
|
-
export const apiApp = createApp({ prefix: "/api" })
|
|
485
|
-
.use(contextPlugin)
|
|
486
|
-
.use(usersApp)
|
|
487
|
-
.get("/health", {}, ({ version }) => ({ version }));
|
|
488
|
-
```
|
|
489
|
-
|
|
490
|
-
```ts
|
|
491
|
-
// index.ts
|
|
492
|
-
const app = createApp().use(apiApp);
|
|
493
|
-
|
|
494
|
-
app.listen(3000);
|
|
495
|
-
```
|
|
496
|
-
|
|
497
|
-
This lets you break your app up into smaller, reusable chunks. If a child app or plugin is used multiple times throughout your app, it is automatically deduplicated so hooks are not ran more than once.
|
|
498
|
-
|
|
499
|
-
## Error Handling
|
|
500
|
-
|
|
501
|
-
By default, Zeta provides built-in error handling, catching an errors thrown inside any handler or hook. It also provides useful error classes that, when thrown, set the specified http status code and maps the error to the response body.
|
|
502
|
-
|
|
503
|
-
```ts
|
|
504
|
-
import { HttpError } from "@aklinker1/zeta/errors";
|
|
505
|
-
import { HttpStatus } from "@aklinker1/zeta/status";
|
|
506
|
-
|
|
507
|
-
const app = createApp().get("/users", {}, () => {
|
|
508
|
-
throw new HttpError(HttpStatus.NotImplemented, "TODO");
|
|
509
|
-
});
|
|
510
|
-
```
|
|
511
|
-
|
|
512
|
-
```ts
|
|
513
|
-
-> GET /users
|
|
514
|
-
<- 501 Not Implemented
|
|
515
|
-
<- {
|
|
516
|
-
<- "name": "HttpError",
|
|
517
|
-
<- "message": "TODO",
|
|
518
|
-
<- "status": 501,
|
|
519
|
-
<- "stack": [...],
|
|
520
|
-
<- "cause": { ... }
|
|
521
|
-
<- }
|
|
522
|
-
```
|
|
523
|
-
|
|
524
|
-
> To not return a stack trace, set `NODE_ENV=production` in the environment variables.
|
|
525
|
-
|
|
526
|
-
Alternatively, you can use the specific error class that extends `HttpError` so you don't have to manually pass the status:
|
|
527
|
-
|
|
528
|
-
```diff
|
|
529
|
-
+import { NotImplementedError } from "@aklinker1/zeta/errors";
|
|
530
|
-
|
|
531
|
-
const app = createApp()
|
|
532
|
-
.get(
|
|
533
|
-
"/users",
|
|
534
|
-
{},
|
|
535
|
-
() => {
|
|
536
|
-
+ throw new NotImplementedError("TODO");
|
|
537
|
-
},
|
|
538
|
-
);
|
|
539
|
-
```
|
|
540
|
-
|
|
541
|
-
When a non-`HttpError` value is thrown, Zeta returns a `500 Internal Server Error` with the original error as the `cause`.
|
|
542
|
-
|
|
543
|
-
## HttpStatus Codes
|
|
544
|
-
|
|
545
|
-
Zeta provides an enum of all HTTP status codes. You should use this instead of literal values for readability.
|
|
546
|
-
|
|
547
|
-
```diff
|
|
548
|
-
import { HttpStatus } from "@aklinker1/zeta/status";
|
|
549
|
-
|
|
550
|
-
const app = createApp()
|
|
551
|
-
.post(
|
|
552
|
-
"/api/users",
|
|
553
|
-
{},
|
|
554
|
-
({ set }) => {
|
|
555
|
-
// ...
|
|
556
|
-
- set.status = 201;
|
|
557
|
-
+ set.status = HttpStatus.Created;
|
|
558
|
-
},
|
|
559
|
-
);
|
|
560
|
-
```
|
|
561
|
-
|
|
562
|
-
## `createClient`
|
|
563
|
-
|
|
564
|
-
If your client-side code is located in the same project as your backend, you can use the TS definition of the top-level app to define a type-safe API client.
|
|
565
|
-
|
|
566
|
-
```ts
|
|
567
|
-
// server/main.ts
|
|
568
|
-
const app = createApp().get("/health", {}, () => ({ status: "up" }));
|
|
569
|
-
|
|
570
|
-
// Export the app's types
|
|
571
|
-
export type App = typeof app;
|
|
572
|
-
```
|
|
573
|
-
|
|
574
|
-
```ts
|
|
575
|
-
// app/api-client.ts
|
|
576
|
-
import type { App } from "../server/main"; // IMPORTANT: Only import types from the server
|
|
577
|
-
import { createClient } from "@aklinker1/zeta/client";
|
|
578
|
-
|
|
579
|
-
export const apiClient = createClient<App>(/* options */);
|
|
580
|
-
|
|
581
|
-
const response = await apiClient.fetch("GET", "/health", {});
|
|
582
|
-
console.log(response); // { status: "up" }
|
|
583
|
-
```
|
|
584
|
-
|
|
585
|
-
**Options:**
|
|
586
|
-
|
|
587
|
-
- **`baseUrl`**: Base URL to prefix all request paths with.
|
|
588
|
-
- _Default: `location.origin`_
|
|
589
|
-
- **`fetch`**: A custom fetch function.
|
|
590
|
-
- _Default: `globalThis.fetch`_
|
|
591
|
-
- **`headers`**: Custom set of default headers to include on every request.
|
|
592
|
-
- _Default: `{}`_
|
|
593
|
-
|
|
594
|
-
The client is type-safe, both input parameters and the response types.
|
|
595
|
-
|
|
596
|
-
When the response status is โฅ400, a `ClientError` is thrown instead of returning a response. It contains the same details as the error thrown on the server.
|
|
597
|
-
|
|
598
|
-
```ts
|
|
599
|
-
try {
|
|
600
|
-
await client.fetch("GET", "/non-existent-route", {});
|
|
601
|
-
} catch (err) {
|
|
602
|
-
console.error(err);
|
|
603
|
-
// ClientError {
|
|
604
|
-
// status: 404,
|
|
605
|
-
// message: "Not found",
|
|
606
|
-
// stack: [...],
|
|
607
|
-
// cause: { ... },
|
|
608
|
-
// }
|
|
609
|
-
}
|
|
29
|
+
```sh
|
|
30
|
+
bun run main.ts
|
|
31
|
+
deno run --allow-net main.ts
|
|
610
32
|
```
|
|
611
33
|
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
If your frontend code is in a different project, since Zeta supports OpenAPI out-of-the-box, you can also use an [OpenAPI code generator](https://duckduckgo.com/?t=ffab&q=openapi+codegen+js&ia=web) to generate an API client for your backend.
|
|
615
|
-
|
|
616
|
-
## `createTestClient`
|
|
617
|
-
|
|
618
|
-
For testing, it's nice to have a type-safe client as well! You can use `createTestClient` to create a client around an app instance.
|
|
619
|
-
|
|
620
|
-
```ts
|
|
621
|
-
import { usersApp } from "../users.ts"; // For tests, you need to import the real app value, NOT it's type.
|
|
622
|
-
import { createTestClient } from "@aklinker1/zeta/testing";
|
|
623
|
-
|
|
624
|
-
const client = createTestClient(usersApp);
|
|
34
|
+
**Features**
|
|
625
35
|
|
|
626
|
-
|
|
627
|
-
|
|
36
|
+
- โ
[Standard schema](https://standardschema.dev/) support (Zod, Arktype, Valibot, etc)
|
|
37
|
+
- ๐งฉ Composable apps, plugins, and routes
|
|
38
|
+
- ๐ค Type-safe server and client side code
|
|
39
|
+
- โ๏ธ WinterCG compatible
|
|
40
|
+
- ๐งช Easy to test
|
|
41
|
+
- ๐ OpenAPI docs built-in
|
|
628
42
|
|
|
629
|
-
|
|
630
|
-
() => client.fetch("GET", "/users/123", {})
|
|
631
|
-
).rejects.toEqual({
|
|
632
|
-
status: HttpStatus.NotFound,
|
|
633
|
-
message: "User not found",
|
|
634
|
-
})
|
|
635
|
-
```
|
|
43
|
+
## Docs
|
|
636
44
|
|
|
637
|
-
|
|
45
|
+
Visit <https://zeta.aklinker1.io> for the full documentation.
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aklinker1/zeta",
|
|
3
3
|
"description": "Composable, testable, OpenAPI-first backend framework with validation built-in",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "2.0.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
|
-
"packageManager": "bun@1.3.
|
|
7
|
+
"packageManager": "bun@1.3.5",
|
|
8
8
|
"module": "src/index.ts",
|
|
9
9
|
"types": "src/index.ts",
|
|
10
10
|
"exports": {
|
|
@@ -41,23 +41,24 @@
|
|
|
41
41
|
"bench": "bun run src/__tests__/bench.ts",
|
|
42
42
|
"example": "bun --watch run example.ts",
|
|
43
43
|
"example:prod": "NODE_ENV=production bun run example",
|
|
44
|
-
"docs:dev": "
|
|
44
|
+
"docs:dev": "zola -r docs serve",
|
|
45
45
|
"docs:build": "vitepress build docs",
|
|
46
46
|
"docs:preview": "vitepress preview docs"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
49
|
"@standard-schema/spec": "^1.0.0",
|
|
50
50
|
"openapi-types": "^12.1.3",
|
|
51
|
-
"rou3": "^0.7.
|
|
51
|
+
"rou3": "^0.7.12",
|
|
52
|
+
"scule": "^1.3.0"
|
|
52
53
|
},
|
|
53
54
|
"devDependencies": {
|
|
54
55
|
"@aklinker1/check": "^2.2.0",
|
|
55
|
-
"@typescript/native-preview": "^7.0.0-dev.20251114.1",
|
|
56
56
|
"@types/bun": "latest",
|
|
57
|
+
"@typescript/native-preview": "^7.0.0-dev.20251114.1",
|
|
57
58
|
"changelogen": "^0.6.2",
|
|
58
|
-
"elysia": "^1.
|
|
59
|
+
"elysia": "^1.4.19",
|
|
59
60
|
"expect-type": "^1.2.1",
|
|
60
|
-
"hono": "^4.
|
|
61
|
+
"hono": "^4.11.2",
|
|
61
62
|
"jsr": "^0.13.5",
|
|
62
63
|
"mermaid": "^11.12.0",
|
|
63
64
|
"oxlint": "^1.2.0",
|