@aklinker1/zeta 1.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 +21 -0
- package/README.md +655 -0
- package/package.json +60 -0
- package/src/adapters/zod-schema-adapter.ts +46 -0
- package/src/app.ts +453 -0
- package/src/client.ts +183 -0
- package/src/custom-responses.ts +166 -0
- package/src/errors.ts +543 -0
- package/src/index.ts +5 -0
- package/src/internal/call-handler.ts +132 -0
- package/src/internal/serialization.ts +72 -0
- package/src/internal/utils.ts +131 -0
- package/src/open-api.ts +234 -0
- package/src/status.ts +143 -0
- package/src/testing.ts +62 -0
- package/src/types.ts +1111 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Aaron Klinker
|
|
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,655 @@
|
|
|
1
|
+
# Zeta
|
|
2
|
+
|
|
3
|
+
[](https://jsr.io/@aklinker1/zeta) [](https://jsr.io/@aklinker1/zeta) [](https://jsr.io/@aklinker1/zeta/doc) [](https://github.com/aklinker1/zeta/blob/main/LICENSE) [](https://github.com/aklinker1/zeta/blob/main/CHANGELOG.md)
|
|
4
|
+
|
|
5
|
+
Personal alternative to [Elysia](https://elysiajs.com/) with better validation support.
|
|
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.
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
import { createApp } from "@aklinker1/zeta";
|
|
73
|
+
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
|
+
|
|
158
|
+
|
|
159
|
+
> 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.
|
|
160
|
+
|
|
161
|
+
### Query Parameters
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
import { z } from "zod";
|
|
165
|
+
|
|
166
|
+
const app = createApp().get(
|
|
167
|
+
"/users",
|
|
168
|
+
{
|
|
169
|
+
query: z.object({
|
|
170
|
+
search: z.string(),
|
|
171
|
+
sortBy: z.enum(["username", "createdAt"]).default("username"),
|
|
172
|
+
sortDirection: z.enum(["asc", "desc"]).default("asc"),
|
|
173
|
+
page: z.coerce.number().int().min(1).default(1),
|
|
174
|
+
pageSize: z.coerce.number().int().min(1).max(100).default(10),
|
|
175
|
+
includeProfile: z.stringbool().default(false),
|
|
176
|
+
}),
|
|
177
|
+
},
|
|
178
|
+
({ query }) => {
|
|
179
|
+
console.log(query);
|
|
180
|
+
// {
|
|
181
|
+
// search: '...',
|
|
182
|
+
// sortBy: 'username',
|
|
183
|
+
// sortDirection: 'asc',
|
|
184
|
+
// page: 1,
|
|
185
|
+
// pageSize: 10
|
|
186
|
+
// }
|
|
187
|
+
},
|
|
188
|
+
);
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### Body
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
import { z } from "zod";
|
|
195
|
+
|
|
196
|
+
const app = createApp().post(
|
|
197
|
+
"/users",
|
|
198
|
+
{
|
|
199
|
+
body: z.object({
|
|
200
|
+
username: z.string(),
|
|
201
|
+
email: z.string(),
|
|
202
|
+
}),
|
|
203
|
+
},
|
|
204
|
+
({ body }) => {
|
|
205
|
+
console.log(body);
|
|
206
|
+
},
|
|
207
|
+
);
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
### Response
|
|
211
|
+
|
|
212
|
+
You can either define a single response or multiple responses.
|
|
213
|
+
|
|
214
|
+
For single response schemas, a `200 OK` status code is assumed.
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
import { z } from "zod";
|
|
218
|
+
|
|
219
|
+
const app = createApp().get(
|
|
220
|
+
"/api/health",
|
|
221
|
+
{
|
|
222
|
+
// Note: The property key is always responses (plural), even when defining a
|
|
223
|
+
// single response schema.
|
|
224
|
+
responses: z.object({
|
|
225
|
+
status: z.literal("up"),
|
|
226
|
+
version: z.string(),
|
|
227
|
+
}),
|
|
228
|
+
},
|
|
229
|
+
() => ({ status: "up", version: "..." }),
|
|
230
|
+
);
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
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:
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
import { ErrorResponse, createApp, HttpStatus } from "@aklinker1/zeta";
|
|
237
|
+
import { NotFoundHttpError } from "@aklinker1/zeta/errors";
|
|
238
|
+
|
|
239
|
+
const app = createApp().post(
|
|
240
|
+
"/api/users",
|
|
241
|
+
{
|
|
242
|
+
body: UserSchema,
|
|
243
|
+
responses: {
|
|
244
|
+
[HttpStatus.Created]: User,
|
|
245
|
+
[HttpStatus.Conflict]: ErrorResponse,
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
async ({ status, body }) => {
|
|
249
|
+
const userExists = await db.doesUserExist(body.email);
|
|
250
|
+
|
|
251
|
+
// For error responses, throwing is the recommended approach.
|
|
252
|
+
// Zeta maps the HttpError's status code to the correct response schema.
|
|
253
|
+
if (userExists) {
|
|
254
|
+
throw new HttpError(
|
|
255
|
+
HttpStatus.Conflict,
|
|
256
|
+
"A user with this email already exists.",
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const newUser = await db.createUser(body);
|
|
261
|
+
|
|
262
|
+
return status(HttpStatus.Created, newUser);
|
|
263
|
+
},
|
|
264
|
+
);
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
> When defining custom error responses, use `ErrorResponse` schema from `@aklinker1/zeta`.
|
|
268
|
+
|
|
269
|
+
When a response schema(s) are defined, the return value from the function is type-safe.
|
|
270
|
+
|
|
271
|
+
#### Custom Content Types
|
|
272
|
+
|
|
273
|
+
By default, Zeta will use `application/json` as the content type in the OpenAPI docs. you can override this 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
|
+
> [!WARNING]
|
|
288
|
+
>
|
|
289
|
+
> Zeta ignores this metadata when building the response. Make sure to set the `Content-Type` header in your handler:
|
|
290
|
+
>
|
|
291
|
+
> ```ts
|
|
292
|
+
> app.get(
|
|
293
|
+
> "/csv",
|
|
294
|
+
> {
|
|
295
|
+
> responses: z.string().meta({ contentType: "text/csv" }),
|
|
296
|
+
> },
|
|
297
|
+
> ({ set }) => {
|
|
298
|
+
> // ...
|
|
299
|
+
> set.headers["Content-Type"] = "text/csv";
|
|
300
|
+
> return "...";
|
|
301
|
+
> },
|
|
302
|
+
> );
|
|
303
|
+
> ```
|
|
304
|
+
|
|
305
|
+
## Life Cycle Hooks
|
|
306
|
+
|
|
307
|
+
## `App#decorate`
|
|
308
|
+
|
|
309
|
+
Shorthand for `.onTransform(() => decorators)`, just adding values to the request context.
|
|
310
|
+
|
|
311
|
+
```ts
|
|
312
|
+
const db = ...;
|
|
313
|
+
const redis = ...;
|
|
314
|
+
|
|
315
|
+
const app = createApp()
|
|
316
|
+
.decorate("db", db)
|
|
317
|
+
.decorate("redis", redis)
|
|
318
|
+
// OR in a single call
|
|
319
|
+
.decorate({ db, redis })
|
|
320
|
+
|
|
321
|
+
// Then you can access the decorated values in the handler
|
|
322
|
+
.get("/path", {}, ({ db, redis }) => {
|
|
323
|
+
// ...
|
|
324
|
+
})
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
## `App#mount`
|
|
328
|
+
|
|
329
|
+
You can add another server-side `fetch` function to the app using the `mount` function:
|
|
330
|
+
|
|
331
|
+
```ts
|
|
332
|
+
const app = createApp().mount((request: Request) => new Response());
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
If no other route defined on the app is matched, the mounted `fetch` function will be called instead.
|
|
336
|
+
|
|
337
|
+
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.
|
|
338
|
+
|
|
339
|
+
```ts
|
|
340
|
+
import { fetchStatic } from "@aklinker1/aframe/server";
|
|
341
|
+
|
|
342
|
+
const app = createApp().use(apiApp).mount(fetchStatic());
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
## `App#build`
|
|
346
|
+
|
|
347
|
+
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.
|
|
348
|
+
|
|
349
|
+
To get this "fetch" function, call the `build` method:
|
|
350
|
+
|
|
351
|
+
```ts
|
|
352
|
+
const app = createApp();
|
|
353
|
+
|
|
354
|
+
const fetch = app.build(); // (request: Request) => MaybePromise<Response>
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
This makes it super easy to test or write scripts for your app without actually serving it over a port:
|
|
358
|
+
|
|
359
|
+
```ts
|
|
360
|
+
import myApp from "./my-app";
|
|
361
|
+
|
|
362
|
+
const fetch = myApp.build();
|
|
363
|
+
|
|
364
|
+
const request = new Request("http://localhost/some-endpoint");
|
|
365
|
+
|
|
366
|
+
const response = await fetch(request);
|
|
367
|
+
console.log(await response.text());
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
> 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.
|
|
371
|
+
|
|
372
|
+
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:
|
|
373
|
+
|
|
374
|
+
```ts
|
|
375
|
+
Deno.serve(app.build());
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
## OpenAPI and Validation
|
|
379
|
+
|
|
380
|
+
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.
|
|
381
|
+
|
|
382
|
+
So for Zeta to properly generate OpenAPI specs, you need to pass in a `schemaAdapter` to the top-level app instance.
|
|
383
|
+
|
|
384
|
+
```ts
|
|
385
|
+
import { createApp } from "@aklinker1/zeta";
|
|
386
|
+
import { zodSchemaAdapter } from "@aklinker1/zeta/adapters/zod-schema-adapter";
|
|
387
|
+
|
|
388
|
+
const app = createApp({
|
|
389
|
+
schemaAdapter: zodSchemaAdapter,
|
|
390
|
+
}).get(
|
|
391
|
+
"/health",
|
|
392
|
+
{
|
|
393
|
+
// You can pass some OpenAPI metadata here:
|
|
394
|
+
summary: "Health Check",
|
|
395
|
+
tags: ["Server"],
|
|
396
|
+
description: "Returns a JSON object with the app's health status",
|
|
397
|
+
operationId: "getHealth",
|
|
398
|
+
// ...
|
|
399
|
+
},
|
|
400
|
+
() => {
|
|
401
|
+
// ...
|
|
402
|
+
},
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
app.listen(3000);
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
Without a schema adapter, Zeta will throw an error when trying to access the `/openapi.json` endpoint, but it's not needed if you only want to validate inputs and response bodies.
|
|
409
|
+
|
|
410
|
+
### Model References
|
|
411
|
+
|
|
412
|
+
By default, Zeta will not put any models in `components.schemas` nor add `$ref` for those models.
|
|
413
|
+
|
|
414
|
+
You are in charge of determining which models should be added to `components.schemas` by adding a `ref` meta to the model's schema:
|
|
415
|
+
|
|
416
|
+
```ts
|
|
417
|
+
import { z } from "zod";
|
|
418
|
+
|
|
419
|
+
const User = z
|
|
420
|
+
.object({
|
|
421
|
+
id: z.string().uuid(),
|
|
422
|
+
email: z.string().email(),
|
|
423
|
+
})
|
|
424
|
+
.meta({
|
|
425
|
+
ref: "User",
|
|
426
|
+
});
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
When building your app's spec, Zeta will find these `ref` properties and move the object schemas into `components.schemas` automatically for you.
|
|
430
|
+
|
|
431
|
+
### Getting the OpenAPI Spec
|
|
432
|
+
|
|
433
|
+
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.
|
|
434
|
+
|
|
435
|
+
```ts
|
|
436
|
+
const app = createApp(...)
|
|
437
|
+
|
|
438
|
+
app.getOpenApiSpec() // { version: "3.1", info: { ... }, paths: { ... }, ... }
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
## Composing Multiple Apps
|
|
442
|
+
|
|
443
|
+
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.
|
|
444
|
+
|
|
445
|
+
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.
|
|
446
|
+
|
|
447
|
+
```ts
|
|
448
|
+
const childApp = createApp()
|
|
449
|
+
.decorate({ db })
|
|
450
|
+
.get("/child-path", {}, ({ db }) => {
|
|
451
|
+
// ✅ `db` is defined
|
|
452
|
+
db.query(...)
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
const parentApp = createApp()
|
|
456
|
+
.use(childApp)
|
|
457
|
+
.get("/parent-path", {}, ({ db }) => {
|
|
458
|
+
// ^^ Type Error: Property "db" does not exist
|
|
459
|
+
})
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
However, after adding `.export()` to the child app, the parent app will have access to the `db` property.
|
|
463
|
+
|
|
464
|
+
```diff
|
|
465
|
+
const childApp = createApp()
|
|
466
|
+
.decorate({ db })
|
|
467
|
+
.get("/child-path", {}, ({ db }) => {
|
|
468
|
+
db.query(...)
|
|
469
|
+
})
|
|
470
|
+
+ .export();
|
|
471
|
+
|
|
472
|
+
const parentApp = createApp()
|
|
473
|
+
.use(childApp)
|
|
474
|
+
.get("/parent-path", {}, ({ db }) => {
|
|
475
|
+
+ // ✅ `db` is defined
|
|
476
|
+
+ db.query(...)
|
|
477
|
+
})
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
The recommended approach for composing multiple apps is to create a set of "plugins", child apps containing shared logic required by multiple different apps.
|
|
481
|
+
|
|
482
|
+
```ts
|
|
483
|
+
// plugins/context-plugin.ts
|
|
484
|
+
export const contextPlugin = createApp().decorate({ db, version }).export();
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
```ts
|
|
488
|
+
// routes/users.ts
|
|
489
|
+
import { contextPlugin } from "../plugins/context-plugin.ts";
|
|
490
|
+
|
|
491
|
+
export const usersPlugin = createApp({ prefix: "/users" })
|
|
492
|
+
.use(contextPlugin)
|
|
493
|
+
.get("/", {}, ({ db }) => {
|
|
494
|
+
// ...
|
|
495
|
+
});
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
```ts
|
|
499
|
+
// routes/api.ts
|
|
500
|
+
import { contextPlugin } from "../plugins/context-plugin.ts";
|
|
501
|
+
|
|
502
|
+
export const apiApp = createApp({ prefix: "/api" })
|
|
503
|
+
.use(contextPlugin)
|
|
504
|
+
.use(usersApp)
|
|
505
|
+
.get("/health", {}, ({ version }) => ({ version }));
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
```ts
|
|
509
|
+
// index.ts
|
|
510
|
+
const app = createApp().use(apiApp);
|
|
511
|
+
|
|
512
|
+
app.listen(3000);
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
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.
|
|
516
|
+
|
|
517
|
+
## Error Handling
|
|
518
|
+
|
|
519
|
+
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.
|
|
520
|
+
|
|
521
|
+
```ts
|
|
522
|
+
import { HttpError } from "@aklinker1/zeta/errors";
|
|
523
|
+
import { HttpStatus } from "@aklinker1/zeta/status";
|
|
524
|
+
|
|
525
|
+
const app = createApp().get("/users", {}, () => {
|
|
526
|
+
throw new HttpError(HttpStatus.NotImplemented, "TODO");
|
|
527
|
+
});
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
```ts
|
|
531
|
+
-> GET /users
|
|
532
|
+
<- 501 Not Implemented
|
|
533
|
+
<- {
|
|
534
|
+
<- "name": "HttpError",
|
|
535
|
+
<- "message": "TODO",
|
|
536
|
+
<- "status": 501,
|
|
537
|
+
<- "stack": [...],
|
|
538
|
+
<- "cause": { ... }
|
|
539
|
+
<- }
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
> To not return a stack trace, set `NODE_ENV=production` in the environment variables.
|
|
543
|
+
|
|
544
|
+
Alternatively, you can use the specific error class that extends `HttpError` so you don't have to manually pass the status:
|
|
545
|
+
|
|
546
|
+
```diff
|
|
547
|
+
+import { NotImplementedError } from "@aklinker1/zeta/errors";
|
|
548
|
+
|
|
549
|
+
const app = createApp()
|
|
550
|
+
.get(
|
|
551
|
+
"/users",
|
|
552
|
+
{},
|
|
553
|
+
() => {
|
|
554
|
+
+ throw new NotImplementedError("TODO");
|
|
555
|
+
},
|
|
556
|
+
);
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
When a non-`HttpError` value is thrown, Zeta returns a `500 Internal Server Error` with the original error as the `cause`.
|
|
560
|
+
|
|
561
|
+
## HttpStatus Codes
|
|
562
|
+
|
|
563
|
+
Zeta provides an enum of all HTTP status codes. You should use this instead of literal values for readability.
|
|
564
|
+
|
|
565
|
+
```diff
|
|
566
|
+
import { HttpStatus } from "@aklinker1/zeta/status";
|
|
567
|
+
|
|
568
|
+
const app = createApp()
|
|
569
|
+
.post(
|
|
570
|
+
"/api/users",
|
|
571
|
+
{},
|
|
572
|
+
({ set }) => {
|
|
573
|
+
// ...
|
|
574
|
+
- set.status = 201;
|
|
575
|
+
+ set.status = HttpStatus.Created;
|
|
576
|
+
},
|
|
577
|
+
);
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
## `createClient`
|
|
581
|
+
|
|
582
|
+
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.
|
|
583
|
+
|
|
584
|
+
```ts
|
|
585
|
+
// server/main.ts
|
|
586
|
+
const app = createApp().get("/health", {}, () => ({ status: "up" }));
|
|
587
|
+
|
|
588
|
+
// Export the app's types
|
|
589
|
+
export type App = typeof app;
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
```ts
|
|
593
|
+
// app/api-client.ts
|
|
594
|
+
import type { App } from "../server/main"; // IMPORTANT: Only import types from the server
|
|
595
|
+
import { createClient } from "@aklinker1/zeta/client";
|
|
596
|
+
|
|
597
|
+
export const apiClient = createClient<App>(/* options */);
|
|
598
|
+
|
|
599
|
+
const response = await apiClient.fetch("GET", "/health", {});
|
|
600
|
+
console.log(response); // { status: "up" }
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
**Options:**
|
|
604
|
+
|
|
605
|
+
- **`baseUrl`**: Base URL to prefix all request paths with.
|
|
606
|
+
- _Default: `location.origin`_
|
|
607
|
+
- **`fetch`**: A custom fetch function.
|
|
608
|
+
- _Default: `globalThis.fetch`_
|
|
609
|
+
- **`headers`**: Custom set of default headers to include on every request.
|
|
610
|
+
- _Default: `{}`_
|
|
611
|
+
|
|
612
|
+
The client is type-safe, both input parameters and the response types.
|
|
613
|
+
|
|
614
|
+
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.
|
|
615
|
+
|
|
616
|
+
```ts
|
|
617
|
+
try {
|
|
618
|
+
await client.fetch("GET", "/non-existent-route", {});
|
|
619
|
+
} catch (err) {
|
|
620
|
+
console.error(err);
|
|
621
|
+
// ClientError {
|
|
622
|
+
// status: 404,
|
|
623
|
+
// message: "Not found",
|
|
624
|
+
// stack: [...],
|
|
625
|
+
// cause: { ... },
|
|
626
|
+
// }
|
|
627
|
+
}
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
The `ClientError` is very similar to the `HttpError` server-side, but it is specific to clients and there aren't subclasses for each status code.
|
|
631
|
+
|
|
632
|
+
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.
|
|
633
|
+
|
|
634
|
+
## `createTestClient`
|
|
635
|
+
|
|
636
|
+
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.
|
|
637
|
+
|
|
638
|
+
```ts
|
|
639
|
+
import { usersApp } from "../users.ts"; // For tests, you need to import the real app value, NOT it's type.
|
|
640
|
+
import { createTestClient } from "@aklinker1/zeta/testing";
|
|
641
|
+
|
|
642
|
+
const client = createTestClient(usersApp);
|
|
643
|
+
|
|
644
|
+
const response = await client.fetch("GET", "/users", {})
|
|
645
|
+
expect(response).toEqual([...]);
|
|
646
|
+
|
|
647
|
+
await expect(
|
|
648
|
+
() => client.fetch("GET", "/users/123", {})
|
|
649
|
+
).rejects.toEqual({
|
|
650
|
+
status: HttpStatus.NotFound,
|
|
651
|
+
message: "User not found",
|
|
652
|
+
})
|
|
653
|
+
```
|
|
654
|
+
|
|
655
|
+
> `createTestClient` uses `createClient` and `app.build` to return the same client instance you would use client-side, but it calls the app's `fetch` function without serving the app on a port.
|