@ozanarslan/corpus 0.0.1
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.txt +20 -0
- package/README.md +337 -0
- package/dist/App/index.d.ts +377 -0
- package/dist/App/index.d.ts.map +1 -0
- package/dist/C.namespace.d.ts +27 -0
- package/dist/C.namespace.d.ts.map +1 -0
- package/dist/Config/index.d.ts +163 -0
- package/dist/Config/index.d.ts.map +1 -0
- package/dist/Context/ContextAccess/index.d.ts +50 -0
- package/dist/Context/ContextAccess/index.d.ts.map +1 -0
- package/dist/Context/index.d.ts +162 -0
- package/dist/Context/index.d.ts.map +1 -0
- package/dist/Controller/index.d.ts +164 -0
- package/dist/Controller/index.d.ts.map +1 -0
- package/dist/Cookies/index.d.ts +81 -0
- package/dist/Cookies/index.d.ts.map +1 -0
- package/dist/Cors/index.d.ts +135 -0
- package/dist/Cors/index.d.ts.map +1 -0
- package/dist/Exception/index.d.ts +85 -0
- package/dist/Exception/index.d.ts.map +1 -0
- package/dist/Globals/AppsRegistry/index.d.ts +48 -0
- package/dist/Globals/AppsRegistry/index.d.ts.map +1 -0
- package/dist/Globals/ParsersRegistry/index.d.ts +93 -0
- package/dist/Globals/ParsersRegistry/index.d.ts.map +1 -0
- package/dist/Globals/index.d.ts +109 -0
- package/dist/Globals/index.d.ts.map +1 -0
- package/dist/Headers/index.d.ts +208 -0
- package/dist/Headers/index.d.ts.map +1 -0
- package/dist/Middleware/index.d.ts +125 -0
- package/dist/Middleware/index.d.ts.map +1 -0
- package/dist/ParserBase/BodyParser/index.d.ts +138 -0
- package/dist/ParserBase/BodyParser/index.d.ts.map +1 -0
- package/dist/ParserBase/FormDataParser/index.d.ts +57 -0
- package/dist/ParserBase/FormDataParser/index.d.ts.map +1 -0
- package/dist/ParserBase/SchemaParser/index.d.ts +143 -0
- package/dist/ParserBase/SchemaParser/index.d.ts.map +1 -0
- package/dist/ParserBase/SearchParamsParser/index.d.ts +61 -0
- package/dist/ParserBase/SearchParamsParser/index.d.ts.map +1 -0
- package/dist/ParserBase/URLParamsParser/index.d.ts +32 -0
- package/dist/ParserBase/URLParamsParser/index.d.ts.map +1 -0
- package/dist/ParserBase/index.d.ts +92 -0
- package/dist/ParserBase/index.d.ts.map +1 -0
- package/dist/RateLimiter/index.d.ts +321 -0
- package/dist/RateLimiter/index.d.ts.map +1 -0
- package/dist/Request/index.d.ts +81 -0
- package/dist/Request/index.d.ts.map +1 -0
- package/dist/Res/index.d.ts +353 -0
- package/dist/Res/index.d.ts.map +1 -0
- package/dist/RouteBase/BundleRoute/index.d.ts +268 -0
- package/dist/RouteBase/BundleRoute/index.d.ts.map +1 -0
- package/dist/RouteBase/FileRoute/index.d.ts +121 -0
- package/dist/RouteBase/FileRoute/index.d.ts.map +1 -0
- package/dist/RouteBase/Route/index.d.ts +83 -0
- package/dist/RouteBase/Route/index.d.ts.map +1 -0
- package/dist/RouteBase/StaticRoute/index.d.ts +149 -0
- package/dist/RouteBase/StaticRoute/index.d.ts.map +1 -0
- package/dist/RouteBase/WebSocketRoute/index.d.ts +117 -0
- package/dist/RouteBase/WebSocketRoute/index.d.ts.map +1 -0
- package/dist/RouteBase/index.d.ts +206 -0
- package/dist/RouteBase/index.d.ts.map +1 -0
- package/dist/Server/index.d.ts +44 -0
- package/dist/Server/index.d.ts.map +1 -0
- package/dist/XFile/index.d.ts +202 -0
- package/dist/XFile/index.d.ts.map +1 -0
- package/dist/exports.d.ts +11 -0
- package/dist/exports.d.ts.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +47 -0
- package/dist/initialize.d.ts +22 -0
- package/dist/initialize.d.ts.map +1 -0
- package/dist/utils/assert.d.ts +7 -0
- package/dist/utils/assert.d.ts.map +1 -0
- package/dist/utils/is.d.ts +17 -0
- package/dist/utils/is.d.ts.map +1 -0
- package/dist/utils/lazy.d.ts +27 -0
- package/dist/utils/lazy.d.ts.map +1 -0
- package/dist/utils/logger.d.ts +31 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/object.d.ts +20 -0
- package/dist/utils/object.d.ts.map +1 -0
- package/dist/utils/path.d.ts +6 -0
- package/dist/utils/path.d.ts.map +1 -0
- package/dist/utils/tuple.d.ts +3 -0
- package/dist/utils/tuple.d.ts.map +1 -0
- package/package.json +57 -0
package/LICENSE.txt
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
Copyright (c) 2026 ozanArslan2424
|
|
3
|
+
|
|
4
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
5
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
6
|
+
in the Software without restriction, including without limitation the rights
|
|
7
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
8
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
9
|
+
furnished to do so, subject to the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be included in all
|
|
12
|
+
copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
15
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
16
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
17
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
18
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
19
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
20
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
# @ozanarslan/corpus
|
|
2
|
+
|
|
3
|
+
[Documentation](https://corpus-docs.fly.dev/)
|
|
4
|
+
|
|
5
|
+
A minimal HTTP framework for [Bun](https://bun.sh), written in TypeScript with zero runtime dependencies.
|
|
6
|
+
|
|
7
|
+
Corpus is built around plain classes. A route is an object you construct, not a callback you hand to a builder — so routes, controllers and middleware are values you can hold, extend, and call directly in a test without starting a server. Request parsing is lazy and analysed ahead of time: a body nobody reads is never parsed, and a route with no schemas skips validation entirely.
|
|
8
|
+
|
|
9
|
+
Validation uses [Standard Schema](https://standardschema.dev), so Zod, Valibot, ArkType and anything else implementing the spec work out of the box, and their inferred types flow into your handlers.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
bun add @ozanarslan/corpus
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Bun 1.2 or newer. Corpus uses `Bun.serve`, `Bun.file` and `Bun.CookieMap`, so it does not run on Node. Node support may be implemented in the future.
|
|
18
|
+
|
|
19
|
+
## Hello world
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { C } from "@ozanarslan/corpus";
|
|
23
|
+
|
|
24
|
+
const app = new C.App({ port: 3000 });
|
|
25
|
+
|
|
26
|
+
new C.Route("GET /", () => ({ message: "hello" }));
|
|
27
|
+
|
|
28
|
+
await app.listen();
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
bun run index.ts
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Constructing a `C.Route` registers it on the most recently constructed `C.App`, so you never pass the app around. Nothing is compiled until `listen()`, which means routes and middleware can be declared in any order across any number of files.
|
|
36
|
+
|
|
37
|
+
## Responses
|
|
38
|
+
|
|
39
|
+
Return a value and corpus works out how to send it — objects become JSON, strings become text, typed arrays stay binary, streams pass through untouched. Each gets a matching `Content-Type` unless you set one yourself.
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
new C.Route("GET /users", () => users); // application/json
|
|
43
|
+
new C.Route("GET /ping", () => "pong"); // text/plain
|
|
44
|
+
new C.Route("GET /avatar", () => imageBytes); // application/octet-stream
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Reach for `C.Res` when you need a status, headers, cookies, or a body form a return value can't express. `Context` holds a fresh value by default so there is no need to construct it again.
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
new C.Route("POST /users", async (c) => {
|
|
51
|
+
const user = await createUser(c.body);
|
|
52
|
+
return new C.Res(user, { status: C.Status.CREATED });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
new C.Route("GET /report", (c) => c.res.file("./report.pdf"));
|
|
56
|
+
new C.Route("GET /old", (c) => c.res.permanentRedirect("/new"));
|
|
57
|
+
|
|
58
|
+
new C.Route("GET /users", (c) => {
|
|
59
|
+
c.res.headers.set("X-Total-Count", users.length);
|
|
60
|
+
c.res.cookies.set("seen", "1");
|
|
61
|
+
return users;
|
|
62
|
+
});
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## The context
|
|
66
|
+
|
|
67
|
+
Every handler receives a `Context` holding the request and its parsed views.
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
new C.Route("POST /users/:id/notes", (c) => {
|
|
71
|
+
c.params; // parsed path parameters
|
|
72
|
+
c.search; // parsed query string
|
|
73
|
+
c.body; // parsed request body
|
|
74
|
+
c.data; // request-scoped state, shared with middleware
|
|
75
|
+
c.req; // the untouched Request
|
|
76
|
+
c.res; // the response being built
|
|
77
|
+
c.url; // the Request's URL object
|
|
78
|
+
});
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`params`, `search` and `body` are populated only when your handler chain actually reads them — corpus inspects the handlers at startup to decide. Query strings and form bodies support bracket nesting, so `?filter[status]=open&tags[0]=a` arrives as a real object, and repeated keys collect into arrays.
|
|
82
|
+
|
|
83
|
+
## Validation
|
|
84
|
+
|
|
85
|
+
Pass schemas as the third argument. They validate the request and type the context:
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
import { z } from "zod";
|
|
89
|
+
|
|
90
|
+
new C.Route(
|
|
91
|
+
"POST /users",
|
|
92
|
+
(c) => createUser(c.body), // c.body is typed from the schema
|
|
93
|
+
{
|
|
94
|
+
body: z.object({ email: z.string().email(), age: z.number() }),
|
|
95
|
+
search: z.object({ notify: z.boolean().optional() }),
|
|
96
|
+
},
|
|
97
|
+
);
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
A failure responds with `422` and a message naming the offending fields and what was received.
|
|
101
|
+
|
|
102
|
+
The same object also takes `maxRequestBodySize`, to tighten the app-wide limit for one endpoint:
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
new C.Route("POST /upload", handler, { maxRequestBodySize: 50 * 1024 * 1024 });
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Errors
|
|
109
|
+
|
|
110
|
+
Throw a `C.Exception` to end a request with a status. Anything else that throws becomes an opaque `500`, so an accidental `TypeError` never leaks its message. Replace `app.handleError` to change how errors are rendered.
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
new C.Route("GET /users/:id", (c) => {
|
|
114
|
+
const user = findUser(c.params.id);
|
|
115
|
+
if (!user) throw new C.Exception("User not found", C.Status.NOT_FOUND);
|
|
116
|
+
return user;
|
|
117
|
+
});
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Middleware
|
|
121
|
+
|
|
122
|
+
A middleware runs around whatever comes after it. Everything before `await next()` happens on the way in, everything after it on the way out. Return early instead of calling `next()` to short-circuit the chain.
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
new C.Middleware({
|
|
126
|
+
handler: async (c, next) => {
|
|
127
|
+
const started = performance.now();
|
|
128
|
+
const result = await next();
|
|
129
|
+
c.res.headers.set("X-Response-Time", performance.now() - started);
|
|
130
|
+
return result;
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
With no `useOn`, it applies to every route. Target specific ones by passing routes, controllers, or route ids:
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
const usersRoute = new C.Route("GET /users", handler);
|
|
139
|
+
|
|
140
|
+
new C.Middleware({
|
|
141
|
+
useOn: usersRoute,
|
|
142
|
+
handler: (c, next) => {
|
|
143
|
+
if (!isAuthorized(c.req)) throw new C.Exception("Unauthorized", C.Status.UNAUTHORIZED);
|
|
144
|
+
return next();
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Use `c.data` to pass state downstream. Augment `ContextDataInterface` to type it:
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
declare module "@ozanarslan/corpus" {
|
|
153
|
+
interface ContextDataInterface {
|
|
154
|
+
user: User;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Controllers
|
|
160
|
+
|
|
161
|
+
A `C.Controller` groups routes under a shared prefix and an optional preamble. Subclassing is recommended.
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
const users = new C.Controller("/users");
|
|
165
|
+
users.beforeEach = (c) => authenticate(c);
|
|
166
|
+
|
|
167
|
+
users.route("GET /", () => listUsers());
|
|
168
|
+
users.route("GET /:id", (c) => findUser(c.params.id));
|
|
169
|
+
users.route("POST /", (c) => createUser(c.body), { body: userSchema });
|
|
170
|
+
|
|
171
|
+
class UserController extends C.Controller {
|
|
172
|
+
constructor(private readonly service: UserService) {
|
|
173
|
+
super("/users");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
list = this.route("GET /", () => this.service.list());
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
The methods mirror the route constructors exactly, so moving a route into a controller only changes the call site. `users.routeIds` gives you the whole group to target with middleware.
|
|
181
|
+
|
|
182
|
+
## Serving files
|
|
183
|
+
|
|
184
|
+
Four route kinds, differing in what they read and when.
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
// One file, read per request.
|
|
188
|
+
new C.FileRoute("GET /robots.txt", "./public/robots.txt");
|
|
189
|
+
new C.FileRoute("GET /report", { filePath: "./report.pdf", disposition: "attachment" });
|
|
190
|
+
|
|
191
|
+
// One file, read once at startup and held in memory.
|
|
192
|
+
new C.StaticRoute("GET /about", "./pages/about.html");
|
|
193
|
+
|
|
194
|
+
// ...optionally passed through a handler first, for templating.
|
|
195
|
+
new C.StaticRoute("GET /", "./pages/index.html", (c, html) => html.replace("{{title}}", title));
|
|
196
|
+
|
|
197
|
+
// A whole directory of built files, with per-file-class caching.
|
|
198
|
+
new C.BundleRoute("/*", "./dist");
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
`C.BundleRoute` answers unresolvable paths with the entry document instead of a 404, which is what keeps a single-page app working on a hard refresh. Hashed assets are cached immutably, the entry document is revalidated every time.
|
|
202
|
+
|
|
203
|
+
## Streaming
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
// Server-sent events
|
|
207
|
+
new C.Route("GET /events", (c) =>
|
|
208
|
+
c.res.sse(async (send) => {
|
|
209
|
+
for await (const event of source) send({ data: event, event: "update" });
|
|
210
|
+
}),
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
// Newline-delimited JSON
|
|
214
|
+
new C.Route("GET /export", (c) =>
|
|
215
|
+
c.res.ndjson(async (send) => {
|
|
216
|
+
for await (const row of rows) send(row);
|
|
217
|
+
}),
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
// A file, without buffering it
|
|
221
|
+
new C.Route("GET /video", (c) => c.res.streamFile("./clip.mp4", "inline"));
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Return a cleanup function from an `sse` or `ndjson` source to keep the stream open indefinitely; it runs when the client disconnects.
|
|
225
|
+
|
|
226
|
+
## WebSockets
|
|
227
|
+
|
|
228
|
+
```ts
|
|
229
|
+
new C.WebSocketRoute("/chat", {
|
|
230
|
+
onOpen: (ws) => ws.subscribe("room"),
|
|
231
|
+
onMessage: (ws, message) => ws.publish("room", message),
|
|
232
|
+
onClose: (ws) => ws.unsubscribe("room"),
|
|
233
|
+
});
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
One route instance backs every connection, so per-socket state belongs on the socket, not on the route.
|
|
237
|
+
|
|
238
|
+
## CORS
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
new C.Cors({
|
|
242
|
+
allowedOrigins: ["https://example.com"],
|
|
243
|
+
allowedMethods: ["GET", "POST"],
|
|
244
|
+
credentials: true,
|
|
245
|
+
});
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
CORS runs after the middleware chain, separately from it, so a short-circuiting middleware can't drop the headers. Without a `C.Cors`, preflights get a bare `204` and no CORS headers are sent.
|
|
249
|
+
|
|
250
|
+
## Rate limiting
|
|
251
|
+
|
|
252
|
+
```ts
|
|
253
|
+
new C.RateLimiter({
|
|
254
|
+
windowMs: 60_000,
|
|
255
|
+
limits: { authenticated: 120, ipBased: 60, fingerprint: 20 },
|
|
256
|
+
});
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Callers are identified by what the request proves about itself — a token, then an IP, then a header fingerprint — each with its own limit, since they differ in how forgeable they are. Identifiers are hashed with a rotating salt, so no token or address is ever held in memory.
|
|
260
|
+
|
|
261
|
+
Construct it after your routes; it targets the ones registered so far, excluding bundle routes. Counting is in-process by default — pass a `store` implementing `RateLimiterStoreInterface` to share state across instances.
|
|
262
|
+
|
|
263
|
+
## Testing routes
|
|
264
|
+
|
|
265
|
+
Routes are objects, so call one directly. No server, no HTTP:
|
|
266
|
+
|
|
267
|
+
```ts
|
|
268
|
+
import { expect, test } from "bun:test";
|
|
269
|
+
|
|
270
|
+
const route = new C.Route("GET /users/:id", (c) => findUser(c.params.id));
|
|
271
|
+
|
|
272
|
+
test("finds a user", async () => {
|
|
273
|
+
const result = await route.handle({ params: { id: 1 } });
|
|
274
|
+
expect(result).toEqual({ id: 1, name: "Ada" });
|
|
275
|
+
});
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
`handle()` takes already-parsed values and runs the handler alone — middleware and validation are the app's job, not the route's. To exercise the full pipeline, use `app.handle(request)`.
|
|
279
|
+
|
|
280
|
+
## Configuration
|
|
281
|
+
|
|
282
|
+
`C.Config` reads environment variables with parsing and fallbacks:
|
|
283
|
+
|
|
284
|
+
```ts
|
|
285
|
+
C.Config.get("PORT", { parser: Number, fallback: 3000 });
|
|
286
|
+
C.Config.require("DATABASE_URL"); // throws if unset
|
|
287
|
+
C.Config.isProd;
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
Augment `Env` to get autocompletion for your own variables:
|
|
291
|
+
|
|
292
|
+
```ts
|
|
293
|
+
declare module "@ozanarslan/corpus" {
|
|
294
|
+
interface Env {
|
|
295
|
+
DATABASE_URL: string;
|
|
296
|
+
JWT_SECRET: string;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
## Replacing the parsers
|
|
302
|
+
|
|
303
|
+
Every parsing decision resolves through one registry, and every slot is typed as an interface rather than a class — so a replacement only has to satisfy the contract.
|
|
304
|
+
|
|
305
|
+
```ts
|
|
306
|
+
C.setParsersRegistry({ bodyParser: new MyBodyParser() });
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
Call it before `app.listen()`. Routes resolve their parsers as they are compiled, so a later override is never reached.
|
|
310
|
+
|
|
311
|
+
## App options
|
|
312
|
+
|
|
313
|
+
```ts
|
|
314
|
+
const app = new C.App({
|
|
315
|
+
port: 3000,
|
|
316
|
+
hostname: "0.0.0.0",
|
|
317
|
+
prefix: "/api",
|
|
318
|
+
idleTimeout: 30,
|
|
319
|
+
maxRequestBodySize: 10 * 1024 * 1024,
|
|
320
|
+
tls: { cert, key },
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
app.handleBeforeListen = () => {
|
|
324
|
+
db.connect();
|
|
325
|
+
};
|
|
326
|
+
app.handleBeforeClose = () => {
|
|
327
|
+
db.disconnect();
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
await app.listen();
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
`listen()` installs `SIGINT` and `SIGTERM` handlers, so the app shuts down cleanly on its own.
|
|
334
|
+
|
|
335
|
+
## License
|
|
336
|
+
|
|
337
|
+
MIT
|