@erenthedeveloper0/zen 0.1.0-alpha.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 +9 -0
- package/README.md +96 -0
- package/dist/index.d.ts +56 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +76 -0
- package/dist/index.js.map +1 -0
- package/dist/lifecycle.d.ts +45 -0
- package/dist/lifecycle.d.ts.map +1 -0
- package/dist/lifecycle.js +75 -0
- package/dist/lifecycle.js.map +1 -0
- package/package.json +73 -0
- package/src/index.ts +104 -0
- package/src/lifecycle.ts +104 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Eren Sümer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img alt="zen.js — a compiler-first web framework" src="https://raw.githubusercontent.com/erenthedeveloper0/zen/main/.github/images/banner-dark.png" width="100%">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
# @erenthedeveloper0/zen
|
|
6
|
+
|
|
7
|
+
**A compiler-first web framework for Node.js.** Express-simple, Fastify-fast,
|
|
8
|
+
typed end to end.
|
|
9
|
+
|
|
10
|
+
> **Alpha.** The API will change before `1.0`. Do not put this in production yet — see
|
|
11
|
+
> [the status section](https://github.com/erenthedeveloper0/zen#status).
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install @erenthedeveloper0/zen@alpha
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { zen } from '@erenthedeveloper0/zen'
|
|
19
|
+
|
|
20
|
+
const app = zen()
|
|
21
|
+
|
|
22
|
+
app.get('/', () => 'Hello world')
|
|
23
|
+
|
|
24
|
+
await app.listen(3000)
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Two concepts: `app.METHOD(path, handler)`, and **the handler returns the
|
|
28
|
+
response**. There is no response object to learn.
|
|
29
|
+
|
|
30
|
+
## What you get
|
|
31
|
+
|
|
32
|
+
Registration is a source language: `ready()` freezes the application and
|
|
33
|
+
compiles the router, every route's pipeline, its validators and response
|
|
34
|
+
serializers, and the request context's own memory layout into generated
|
|
35
|
+
JavaScript. Stages a route does not use are not emitted at all.
|
|
36
|
+
|
|
37
|
+
- **Typed routes** from the path template and your schemas — Zod, Valibot and
|
|
38
|
+
ArkType work through [Standard Schema](https://standardschema.dev) with no
|
|
39
|
+
adapter.
|
|
40
|
+
- **Response contracts**: a route that declares `response: { 200: User }`
|
|
41
|
+
compiles a serializer that *cannot* emit a field `User` does not declare.
|
|
42
|
+
- **Deadlines**, **health and readiness endpoints**, **validated configuration**,
|
|
43
|
+
**content negotiation**, **server-sent events**, **file responses** with
|
|
44
|
+
`ETag`/`Range`, and **graceful shutdown** that drains before it refuses.
|
|
45
|
+
- **Boot-time diagnostics**, aggregated: every registration problem in one run,
|
|
46
|
+
each with a fix.
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import { zen } from '@erenthedeveloper0/zen'
|
|
50
|
+
import { z } from 'zod'
|
|
51
|
+
|
|
52
|
+
const app = zen({ timeout: '30s' })
|
|
53
|
+
|
|
54
|
+
app.get('/users/:id<int>', {
|
|
55
|
+
response: { 200: z.object({ id: z.number(), name: z.string() }) },
|
|
56
|
+
}, async (ctx) => {
|
|
57
|
+
const user = await db.users.find(ctx.params.id) // ctx.params.id is a number
|
|
58
|
+
return user // a passwordHash on it never reaches the wire
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
await app.listen() // address from config.server
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## This package
|
|
65
|
+
|
|
66
|
+
`@erenthedeveloper0/zen` wires together:
|
|
67
|
+
|
|
68
|
+
| Package | Role |
|
|
69
|
+
| --- | --- |
|
|
70
|
+
| [`@erenthedeveloper0/zen-core`](https://www.npmjs.com/package/@erenthedeveloper0/zen-core) | registries, compilers, runtime, errors — zero dependencies |
|
|
71
|
+
| [`@erenthedeveloper0/zen-router`](https://www.npmjs.com/package/@erenthedeveloper0/zen-router) | the compiled radix router |
|
|
72
|
+
| [`@erenthedeveloper0/zen-adapter-node`](https://www.npmjs.com/package/@erenthedeveloper0/zen-adapter-node) | Node's `http` server |
|
|
73
|
+
| [`@erenthedeveloper0/zen-middleware`](https://www.npmjs.com/package/@erenthedeveloper0/zen-middleware) | CORS, security headers, request ids, rate limiting |
|
|
74
|
+
|
|
75
|
+
and re-exports all of them, so one import is enough. It also supplies the two
|
|
76
|
+
things only a Node process has: `process.env` as the configuration environment,
|
|
77
|
+
and signal handling — `SIGTERM`/`SIGINT` run the graceful shutdown and exit, and
|
|
78
|
+
an uncaught exception is logged and does the same with exit code 1. Pass
|
|
79
|
+
`lifecycle: false` if something else manages the process.
|
|
80
|
+
|
|
81
|
+
OpenAPI generation is a separate install:
|
|
82
|
+
[`@erenthedeveloper0/zen-openapi`](https://www.npmjs.com/package/@erenthedeveloper0/zen-openapi).
|
|
83
|
+
|
|
84
|
+
## Requirements
|
|
85
|
+
|
|
86
|
+
- Node.js **≥ 22.6**
|
|
87
|
+
- TypeScript **≥ 5.0**, if you use TypeScript
|
|
88
|
+
|
|
89
|
+
## Documentation
|
|
90
|
+
|
|
91
|
+
- [README](https://github.com/erenthedeveloper0/zen#readme) — the tour
|
|
92
|
+
- [ARCHITECTURE.md](https://github.com/erenthedeveloper0/zen/blob/main/ARCHITECTURE.md) — the design, and the arguments that lost
|
|
93
|
+
- [Error codes](https://github.com/erenthedeveloper0/zen/blob/main/docs/errors.md)
|
|
94
|
+
- [Examples](https://github.com/erenthedeveloper0/zen/tree/main/examples)
|
|
95
|
+
|
|
96
|
+
[MIT](https://github.com/erenthedeveloper0/zen/blob/main/LICENSE) © [Eren Sümer](https://github.com/erenthedeveloper0) · [contributors](https://github.com/erenthedeveloper0/zen/blob/main/CONTRIBUTORS.md)
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { ZenApp, type HostLifecycle, type ZenOptions } from '@erenthedeveloper0/zen-core';
|
|
2
|
+
/**
|
|
3
|
+
* The meta-package — rfcs/0001 §24.1.
|
|
4
|
+
*
|
|
5
|
+
* Beginners install one thing (`zen`); experts install six (`@erenthedeveloper0/zen-core`,
|
|
6
|
+
* `@erenthedeveloper0/zen-router`, an adapter, …). This module is the *only* place the three
|
|
7
|
+
* are wired together, which is what keeps `@erenthedeveloper0/zen-core` free of any router or
|
|
8
|
+
* platform dependency.
|
|
9
|
+
*/
|
|
10
|
+
export type ZenAppOptions<C = Record<string, never>> = Partial<Omit<ZenOptions<C>, 'router' | 'pathParser' | 'lifecycle'>> & {
|
|
11
|
+
readonly router?: ZenOptions['router'] | undefined;
|
|
12
|
+
readonly pathParser?: ZenOptions['pathParser'] | undefined;
|
|
13
|
+
/**
|
|
14
|
+
* Signals and crash handling — §4.5, §12.8. Defaults to
|
|
15
|
+
* `processLifecycle()`: `SIGTERM`/`SIGINT` drain and exit, an uncaught
|
|
16
|
+
* error is logged and shuts down with exit 1. `false` leaves the process
|
|
17
|
+
* alone, for a host that manages it itself.
|
|
18
|
+
*/
|
|
19
|
+
readonly lifecycle?: HostLifecycle | false | undefined;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* The five-line app:
|
|
23
|
+
*
|
|
24
|
+
* import { zen } from '@erenthedeveloper0/zen'
|
|
25
|
+
* const app = zen()
|
|
26
|
+
* app.get('/', () => 'Hello world')
|
|
27
|
+
* app.listen({ port: 3000 })
|
|
28
|
+
*
|
|
29
|
+
* Two concepts — `app.METHOD(path, handler)`, and *the handler returns the
|
|
30
|
+
* response*. That is one fewer than Express, because there is no response
|
|
31
|
+
* object to learn (§1.2).
|
|
32
|
+
*/
|
|
33
|
+
export declare function zen<X = {}, C = Record<string, never>>(options?: ZenAppOptions<C>): ZenApp<X & {
|
|
34
|
+
readonly config: C;
|
|
35
|
+
}>;
|
|
36
|
+
export default zen;
|
|
37
|
+
export * from '@erenthedeveloper0/zen-core';
|
|
38
|
+
export { ZenRouter, parsePath, renderPath, BUILTIN_PARAM_TYPES, analyzeRoutes } from '@erenthedeveloper0/zen-router';
|
|
39
|
+
export { nodeAdapter, NODE_CAPABILITIES, mediaTypeFor, type NodeAdapterOptions } from '@erenthedeveloper0/zen-adapter-node';
|
|
40
|
+
export { processLifecycle, type ProcessLifecycleOptions } from './lifecycle.ts';
|
|
41
|
+
/**
|
|
42
|
+
* The first-party middleware pack — §24.2's "common middleware" row.
|
|
43
|
+
*
|
|
44
|
+
* Re-exported here and importable from `@erenthedeveloper0/zen-middleware` directly; §21.2's
|
|
45
|
+
* example uses the second form and `examples/middleware` follows it, so both
|
|
46
|
+
* paths stay exercised.
|
|
47
|
+
*
|
|
48
|
+
* Adding this row is what turned `@erenthedeveloper0/zen-core`'s `requestId` — the ULID
|
|
49
|
+
* generator, exported since 0.1 and imported by nothing outside core — into a
|
|
50
|
+
* live collision with the plugin of the same name, where `app.use(requestId())`
|
|
51
|
+
* would have registered a *string* as middleware and failed at compile with a
|
|
52
|
+
* message about neither. The generator is now `generateRequestId`, which is
|
|
53
|
+
* what it always was.
|
|
54
|
+
*/
|
|
55
|
+
export * from '@erenthedeveloper0/zen-middleware';
|
|
56
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAa,MAAM,EAAE,KAAK,aAAa,EAAE,KAAK,UAAU,EAAE,MAAM,6BAA6B,CAAA;AAKpG;;;;;;;GAOG;AACH,MAAM,MAAM,aAAa,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,IACjD,OAAO,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC,EAAE,QAAQ,GAAG,YAAY,GAAG,WAAW,CAAC,CAAC,GAAG;IACpE,QAAQ,CAAC,MAAM,CAAC,EAAE,UAAU,CAAC,QAAQ,CAAC,GAAG,SAAS,CAAA;IAClD,QAAQ,CAAC,UAAU,CAAC,EAAE,UAAU,CAAC,YAAY,CAAC,GAAG,SAAS,CAAA;IAC1D;;;;;OAKG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,aAAa,GAAG,KAAK,GAAG,SAAS,CAAA;CACvD,CAAA;AA4BH;;;;;;;;;;;GAWG;AACH,wBAAgB,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,EACnD,OAAO,GAAE,aAAa,CAAC,CAAC,CAAM,GAC7B,MAAM,CAAC,CAAC,GAAG;IAAE,QAAQ,CAAC,MAAM,EAAE,CAAC,CAAA;CAAE,CAAC,CAapC;AAED,eAAe,GAAG,CAAA;AAGlB,cAAc,6BAA6B,CAAA;AAC3C,OAAO,EAAE,SAAS,EAAE,SAAS,EAAE,UAAU,EAAE,mBAAmB,EAAE,aAAa,EAAE,MAAM,+BAA+B,CAAA;AACpH,OAAO,EAAE,WAAW,EAAE,iBAAiB,EAAE,YAAY,EAAE,KAAK,kBAAkB,EAAE,MAAM,qCAAqC,CAAA;AAC3H,OAAO,EAAE,gBAAgB,EAAE,KAAK,uBAAuB,EAAE,MAAM,gBAAgB,CAAA;AAE/E;;;;;;;;;;;;;GAaG;AACH,cAAc,mCAAmC,CAAA"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { createApp, ZenApp } from '@erenthedeveloper0/zen-core';
|
|
2
|
+
import { ZenRouter, parsePath } from '@erenthedeveloper0/zen-router';
|
|
3
|
+
import { nodeAdapter } from '@erenthedeveloper0/zen-adapter-node';
|
|
4
|
+
import { processLifecycle } from "./lifecycle.js";
|
|
5
|
+
/**
|
|
6
|
+
* The process environment, or nothing — rfcs/0001 §16.1 layer 6.
|
|
7
|
+
*
|
|
8
|
+
* This is the one line in the project that reaches for `process`, and it is
|
|
9
|
+
* here rather than in `@erenthedeveloper0/zen-core` on purpose: `process` does not exist on
|
|
10
|
+
* workerd, where the environment arrives as an argument to the fetch handler,
|
|
11
|
+
* so a core that read it would be a core that cannot run there (§3.3 B2). The
|
|
12
|
+
* meta-package already knows it is on Node — it imports the Node adapter — so
|
|
13
|
+
* it is the right place to know where the environment lives, and an app that
|
|
14
|
+
* wants a different source passes `env:` explicitly.
|
|
15
|
+
*
|
|
16
|
+
* Read through `globalThis` rather than the bare identifier so that a runtime
|
|
17
|
+
* without it produces `{}` instead of a `ReferenceError` on import.
|
|
18
|
+
*/
|
|
19
|
+
function processEnv() {
|
|
20
|
+
const global = globalThis;
|
|
21
|
+
return global.process?.env ?? {};
|
|
22
|
+
}
|
|
23
|
+
const defaultPathParser = {
|
|
24
|
+
parse(path) {
|
|
25
|
+
const parsed = parsePath(path);
|
|
26
|
+
return { path: parsed.path, segments: parsed.segments };
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* The five-line app:
|
|
31
|
+
*
|
|
32
|
+
* import { zen } from '@erenthedeveloper0/zen'
|
|
33
|
+
* const app = zen()
|
|
34
|
+
* app.get('/', () => 'Hello world')
|
|
35
|
+
* app.listen({ port: 3000 })
|
|
36
|
+
*
|
|
37
|
+
* Two concepts — `app.METHOD(path, handler)`, and *the handler returns the
|
|
38
|
+
* response*. That is one fewer than Express, because there is no response
|
|
39
|
+
* object to learn (§1.2).
|
|
40
|
+
*/
|
|
41
|
+
export function zen(options = {}) {
|
|
42
|
+
return createApp({
|
|
43
|
+
...options,
|
|
44
|
+
router: options.router ?? new ZenRouter(),
|
|
45
|
+
pathParser: options.pathParser ?? defaultPathParser,
|
|
46
|
+
adapter: options.adapter ?? nodeAdapter(),
|
|
47
|
+
// §16.1 layer 6. Explicit `env` — including a list of `.env` sources —
|
|
48
|
+
// always wins; this is only the default nobody should have to write.
|
|
49
|
+
env: options.env ?? processEnv(),
|
|
50
|
+
// §4.5, §12.8 — installed at `listen()`, so an app that is only ever
|
|
51
|
+
// `inject()`ed in a test never touches the process.
|
|
52
|
+
lifecycle: options.lifecycle === false ? undefined : (options.lifecycle ?? processLifecycle()),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
export default zen;
|
|
56
|
+
// Re-export the full public surface so `import { … } from '@erenthedeveloper0/zen'` is enough.
|
|
57
|
+
export * from '@erenthedeveloper0/zen-core';
|
|
58
|
+
export { ZenRouter, parsePath, renderPath, BUILTIN_PARAM_TYPES, analyzeRoutes } from '@erenthedeveloper0/zen-router';
|
|
59
|
+
export { nodeAdapter, NODE_CAPABILITIES, mediaTypeFor } from '@erenthedeveloper0/zen-adapter-node';
|
|
60
|
+
export { processLifecycle } from "./lifecycle.js";
|
|
61
|
+
/**
|
|
62
|
+
* The first-party middleware pack — §24.2's "common middleware" row.
|
|
63
|
+
*
|
|
64
|
+
* Re-exported here and importable from `@erenthedeveloper0/zen-middleware` directly; §21.2's
|
|
65
|
+
* example uses the second form and `examples/middleware` follows it, so both
|
|
66
|
+
* paths stay exercised.
|
|
67
|
+
*
|
|
68
|
+
* Adding this row is what turned `@erenthedeveloper0/zen-core`'s `requestId` — the ULID
|
|
69
|
+
* generator, exported since 0.1 and imported by nothing outside core — into a
|
|
70
|
+
* live collision with the plugin of the same name, where `app.use(requestId())`
|
|
71
|
+
* would have registered a *string* as middleware and failed at compile with a
|
|
72
|
+
* message about neither. The generator is now `generateRequestId`, which is
|
|
73
|
+
* what it always was.
|
|
74
|
+
*/
|
|
75
|
+
export * from '@erenthedeveloper0/zen-middleware';
|
|
76
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,EAAuC,MAAM,6BAA6B,CAAA;AACpG,OAAO,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,+BAA+B,CAAA;AACpE,OAAO,EAAE,WAAW,EAAE,MAAM,qCAAqC,CAAA;AACjE,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAA;AAuBjD;;;;;;;;;;;;;GAaG;AACH,SAAS,UAAU;IACjB,MAAM,MAAM,GAAG,UAAwE,CAAA;IACvF,OAAO,MAAM,CAAC,OAAO,EAAE,GAAG,IAAI,EAAE,CAAA;AAClC,CAAC;AAED,MAAM,iBAAiB,GAA6B;IAClD,KAAK,CAAC,IAAY;QAChB,MAAM,MAAM,GAAG,SAAS,CAAC,IAAI,CAAC,CAAA;QAC9B,OAAO,EAAE,IAAI,EAAE,MAAM,CAAC,IAAI,EAAE,QAAQ,EAAE,MAAM,CAAC,QAAQ,EAAE,CAAA;IACzD,CAAC;CACF,CAAA;AAED;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,GAAG,CACjB,UAA4B,EAAE;IAE9B,OAAO,SAAS,CAAO;QACrB,GAAG,OAAO;QACV,MAAM,EAAE,OAAO,CAAC,MAAM,IAAI,IAAI,SAAS,EAAE;QACzC,UAAU,EAAE,OAAO,CAAC,UAAU,IAAI,iBAAiB;QACnD,OAAO,EAAE,OAAO,CAAC,OAAO,IAAI,WAAW,EAAE;QACzC,uEAAuE;QACvE,qEAAqE;QACrE,GAAG,EAAE,OAAO,CAAC,GAAG,IAAI,UAAU,EAAE;QAChC,qEAAqE;QACrE,oDAAoD;QACpD,SAAS,EAAE,OAAO,CAAC,SAAS,KAAK,KAAK,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,SAAS,IAAI,gBAAgB,EAAE,CAAC;KAC/F,CAAC,CAAA;AACJ,CAAC;AAED,eAAe,GAAG,CAAA;AAElB,+FAA+F;AAC/F,cAAc,6BAA6B,CAAA;AAC3C,OAAO,EAAE,SAAS,EAAE,SAAS,EAAE,UAAU,EAAE,mBAAmB,EAAE,aAAa,EAAE,MAAM,+BAA+B,CAAA;AACpH,OAAO,EAAE,WAAW,EAAE,iBAAiB,EAAE,YAAY,EAA2B,MAAM,qCAAqC,CAAA;AAC3H,OAAO,EAAE,gBAAgB,EAAgC,MAAM,gBAAgB,CAAA;AAE/E;;;;;;;;;;;;;GAaG;AACH,cAAc,mCAAmC,CAAA"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { HostLifecycle } from '@erenthedeveloper0/zen-core';
|
|
2
|
+
export interface ProcessLifecycleOptions {
|
|
3
|
+
/**
|
|
4
|
+
* Signals that start a graceful shutdown. `SIGTERM` is what Kubernetes, ECS,
|
|
5
|
+
* systemd and `docker stop` send; `SIGINT` is Ctrl+C.
|
|
6
|
+
*/
|
|
7
|
+
readonly signals?: readonly string[] | undefined;
|
|
8
|
+
/**
|
|
9
|
+
* Exit once shutdown finishes — 0 after a signal, 1 after a crash. On by
|
|
10
|
+
* default, because a process whose server has closed and whose pools are
|
|
11
|
+
* disposed has nothing left to do, and one that lingers holds its container.
|
|
12
|
+
*/
|
|
13
|
+
readonly exit?: boolean | undefined;
|
|
14
|
+
/**
|
|
15
|
+
* §12.8: on an uncaught exception or unhandled rejection, log it at `fatal`
|
|
16
|
+
* and shut down with exit code 1. On by default.
|
|
17
|
+
*/
|
|
18
|
+
readonly crashes?: boolean | undefined;
|
|
19
|
+
/** Called when a shutdown signal arrives, before anything closes. */
|
|
20
|
+
readonly onSignal?: ((signal: string) => void) | undefined;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The Node process's side of the lifecycle — rfcs/0001 §4.5, §12.8.
|
|
24
|
+
*
|
|
25
|
+
* `zen()` installs this by default when the app starts listening. Before it
|
|
26
|
+
* existed, §4.5 described what happens "on SIGTERM" and nothing listened for
|
|
27
|
+
* one: Node's default action ended the process on the spot, so readiness never
|
|
28
|
+
* went red, the drain window never opened, and `onClose` never ran — on every
|
|
29
|
+
* rolling deploy, which is the exact situation §4.5 exists for. Every example
|
|
30
|
+
* in this repository wrote the same four lines to fill the gap, and two did
|
|
31
|
+
* not.
|
|
32
|
+
*
|
|
33
|
+
* - **First signal:** run `app.close()` — readiness drains, the socket
|
|
34
|
+
* closes, in-flight requests finish, `onClose` runs, services dispose — then
|
|
35
|
+
* exit 0.
|
|
36
|
+
* - **Second signal while that is in progress:** exit now. Ctrl+C twice
|
|
37
|
+
* means "stop waiting", and a shutdown stuck behind a hung connection
|
|
38
|
+
* should not be un-killable from a terminal.
|
|
39
|
+
* - **An uncaught exception or unhandled rejection:** log it at `fatal`, then
|
|
40
|
+
* the same graceful shutdown with exit 1. Zen does not keep serving from a
|
|
41
|
+
* process whose state is unknown (§12.8) — that instinct is how corrupted
|
|
42
|
+
* data gets written — but it does let requests already in flight finish.
|
|
43
|
+
*/
|
|
44
|
+
export declare function processLifecycle(options?: ProcessLifecycleOptions): HostLifecycle;
|
|
45
|
+
//# sourceMappingURL=lifecycle.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"lifecycle.d.ts","sourceRoot":"","sources":["../src/lifecycle.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAe,aAAa,EAAE,MAAM,6BAA6B,CAAA;AAE7E,MAAM,WAAW,uBAAuB;IACtC;;;OAGG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,GAAG,SAAS,CAAA;IAChD;;;;OAIG;IACH,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,GAAG,SAAS,CAAA;IACnC;;;OAGG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,GAAG,SAAS,CAAA;IACtC,qEAAqE;IACrE,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC,GAAG,SAAS,CAAA;CAC3D;AAID;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,GAAE,uBAA4B,GAAG,aAAa,CAwDrF"}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Node process's side of the lifecycle — rfcs/0001 §4.5, §12.8.
|
|
3
|
+
*
|
|
4
|
+
* `zen()` installs this by default when the app starts listening. Before it
|
|
5
|
+
* existed, §4.5 described what happens "on SIGTERM" and nothing listened for
|
|
6
|
+
* one: Node's default action ended the process on the spot, so readiness never
|
|
7
|
+
* went red, the drain window never opened, and `onClose` never ran — on every
|
|
8
|
+
* rolling deploy, which is the exact situation §4.5 exists for. Every example
|
|
9
|
+
* in this repository wrote the same four lines to fill the gap, and two did
|
|
10
|
+
* not.
|
|
11
|
+
*
|
|
12
|
+
* - **First signal:** run `app.close()` — readiness drains, the socket
|
|
13
|
+
* closes, in-flight requests finish, `onClose` runs, services dispose — then
|
|
14
|
+
* exit 0.
|
|
15
|
+
* - **Second signal while that is in progress:** exit now. Ctrl+C twice
|
|
16
|
+
* means "stop waiting", and a shutdown stuck behind a hung connection
|
|
17
|
+
* should not be un-killable from a terminal.
|
|
18
|
+
* - **An uncaught exception or unhandled rejection:** log it at `fatal`, then
|
|
19
|
+
* the same graceful shutdown with exit 1. Zen does not keep serving from a
|
|
20
|
+
* process whose state is unknown (§12.8) — that instinct is how corrupted
|
|
21
|
+
* data gets written — but it does let requests already in flight finish.
|
|
22
|
+
*/
|
|
23
|
+
export function processLifecycle(options = {}) {
|
|
24
|
+
const signals = options.signals ?? ['SIGTERM', 'SIGINT'];
|
|
25
|
+
const exit = options.exit ?? true;
|
|
26
|
+
const crashes = options.crashes ?? true;
|
|
27
|
+
return {
|
|
28
|
+
install(control) {
|
|
29
|
+
const proc = globalThis.process;
|
|
30
|
+
if (proc === undefined || typeof proc.on !== 'function')
|
|
31
|
+
return () => { };
|
|
32
|
+
let stopping = false;
|
|
33
|
+
const shutdown = (reason, code) => {
|
|
34
|
+
if (stopping) {
|
|
35
|
+
control.log.warn({ reason }, 'second shutdown request while shutting down; exiting now');
|
|
36
|
+
proc.exit(code === 0 ? 130 : code);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
stopping = true;
|
|
40
|
+
control.close(reason).then(() => { if (exit)
|
|
41
|
+
proc.exit(code); }, (error) => {
|
|
42
|
+
control.log.fatal({ err: error }, 'shutdown failed');
|
|
43
|
+
proc.exit(1);
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
const onSignal = (signal) => {
|
|
47
|
+
options.onSignal?.(signal);
|
|
48
|
+
shutdown(signal, 0);
|
|
49
|
+
};
|
|
50
|
+
const onException = (error) => {
|
|
51
|
+
control.log.fatal({ err: error }, 'uncaught exception; shutting down');
|
|
52
|
+
shutdown('uncaughtException', 1);
|
|
53
|
+
};
|
|
54
|
+
const onRejection = (reason) => {
|
|
55
|
+
control.log.fatal({ err: reason }, 'unhandled promise rejection; shutting down');
|
|
56
|
+
shutdown('unhandledRejection', 1);
|
|
57
|
+
};
|
|
58
|
+
for (const signal of signals)
|
|
59
|
+
proc.on(signal, onSignal);
|
|
60
|
+
if (crashes) {
|
|
61
|
+
proc.on('uncaughtException', onException);
|
|
62
|
+
proc.on('unhandledRejection', onRejection);
|
|
63
|
+
}
|
|
64
|
+
return () => {
|
|
65
|
+
for (const signal of signals)
|
|
66
|
+
proc.off(signal, onSignal);
|
|
67
|
+
if (crashes) {
|
|
68
|
+
proc.off('uncaughtException', onException);
|
|
69
|
+
proc.off('unhandledRejection', onRejection);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
//# sourceMappingURL=lifecycle.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"lifecycle.js","sourceRoot":"","sources":["../src/lifecycle.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@erenthedeveloper0/zen",
|
|
3
|
+
"version": "0.1.0-alpha.1",
|
|
4
|
+
"description": "Zen: a compiler-first web framework for Node.js. Routing, validation, serialization and the request context are compiled at boot.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"web",
|
|
7
|
+
"framework",
|
|
8
|
+
"http",
|
|
9
|
+
"server",
|
|
10
|
+
"api",
|
|
11
|
+
"rest",
|
|
12
|
+
"typescript",
|
|
13
|
+
"openapi",
|
|
14
|
+
"validation",
|
|
15
|
+
"compiler",
|
|
16
|
+
"zen"
|
|
17
|
+
],
|
|
18
|
+
"homepage": "https://github.com/erenthedeveloper0/zen/tree/main/packages/zen#readme",
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/erenthedeveloper0/zen/issues"
|
|
21
|
+
},
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/erenthedeveloper0/zen.git",
|
|
25
|
+
"directory": "packages/zen"
|
|
26
|
+
},
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"author": {
|
|
29
|
+
"name": "Eren Sümer",
|
|
30
|
+
"url": "https://github.com/erenthedeveloper0"
|
|
31
|
+
},
|
|
32
|
+
"type": "module",
|
|
33
|
+
"sideEffects": false,
|
|
34
|
+
"main": "./dist/index.js",
|
|
35
|
+
"types": "./dist/index.d.ts",
|
|
36
|
+
"exports": {
|
|
37
|
+
".": {
|
|
38
|
+
"types": "./dist/index.d.ts",
|
|
39
|
+
"default": "./dist/index.js"
|
|
40
|
+
},
|
|
41
|
+
"./package.json": "./package.json"
|
|
42
|
+
},
|
|
43
|
+
"files": [
|
|
44
|
+
"dist",
|
|
45
|
+
"src",
|
|
46
|
+
"README.md",
|
|
47
|
+
"LICENSE"
|
|
48
|
+
],
|
|
49
|
+
"engines": {
|
|
50
|
+
"node": ">=22.6.0"
|
|
51
|
+
},
|
|
52
|
+
"publishConfig": {
|
|
53
|
+
"access": "public"
|
|
54
|
+
},
|
|
55
|
+
"scripts": {
|
|
56
|
+
"build": "tsc -b",
|
|
57
|
+
"test": "node --test \"test/**/*.test.ts\""
|
|
58
|
+
},
|
|
59
|
+
"dependencies": {
|
|
60
|
+
"@erenthedeveloper0/zen-adapter-node": "0.1.0-alpha.1",
|
|
61
|
+
"@erenthedeveloper0/zen-core": "0.1.0-alpha.1",
|
|
62
|
+
"@erenthedeveloper0/zen-middleware": "0.1.0-alpha.1",
|
|
63
|
+
"@erenthedeveloper0/zen-router": "0.1.0-alpha.1"
|
|
64
|
+
},
|
|
65
|
+
"peerDependencies": {
|
|
66
|
+
"typescript": ">=5.0"
|
|
67
|
+
},
|
|
68
|
+
"peerDependenciesMeta": {
|
|
69
|
+
"typescript": {
|
|
70
|
+
"optional": true
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { createApp, ZenApp, type HostLifecycle, type ZenOptions } from '@erenthedeveloper0/zen-core'
|
|
2
|
+
import { ZenRouter, parsePath } from '@erenthedeveloper0/zen-router'
|
|
3
|
+
import { nodeAdapter } from '@erenthedeveloper0/zen-adapter-node'
|
|
4
|
+
import { processLifecycle } from './lifecycle.ts'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The meta-package — rfcs/0001 §24.1.
|
|
8
|
+
*
|
|
9
|
+
* Beginners install one thing (`zen`); experts install six (`@erenthedeveloper0/zen-core`,
|
|
10
|
+
* `@erenthedeveloper0/zen-router`, an adapter, …). This module is the *only* place the three
|
|
11
|
+
* are wired together, which is what keeps `@erenthedeveloper0/zen-core` free of any router or
|
|
12
|
+
* platform dependency.
|
|
13
|
+
*/
|
|
14
|
+
export type ZenAppOptions<C = Record<string, never>> =
|
|
15
|
+
Partial<Omit<ZenOptions<C>, 'router' | 'pathParser' | 'lifecycle'>> & {
|
|
16
|
+
readonly router?: ZenOptions['router'] | undefined
|
|
17
|
+
readonly pathParser?: ZenOptions['pathParser'] | undefined
|
|
18
|
+
/**
|
|
19
|
+
* Signals and crash handling — §4.5, §12.8. Defaults to
|
|
20
|
+
* `processLifecycle()`: `SIGTERM`/`SIGINT` drain and exit, an uncaught
|
|
21
|
+
* error is logged and shuts down with exit 1. `false` leaves the process
|
|
22
|
+
* alone, for a host that manages it itself.
|
|
23
|
+
*/
|
|
24
|
+
readonly lifecycle?: HostLifecycle | false | undefined
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The process environment, or nothing — rfcs/0001 §16.1 layer 6.
|
|
29
|
+
*
|
|
30
|
+
* This is the one line in the project that reaches for `process`, and it is
|
|
31
|
+
* here rather than in `@erenthedeveloper0/zen-core` on purpose: `process` does not exist on
|
|
32
|
+
* workerd, where the environment arrives as an argument to the fetch handler,
|
|
33
|
+
* so a core that read it would be a core that cannot run there (§3.3 B2). The
|
|
34
|
+
* meta-package already knows it is on Node — it imports the Node adapter — so
|
|
35
|
+
* it is the right place to know where the environment lives, and an app that
|
|
36
|
+
* wants a different source passes `env:` explicitly.
|
|
37
|
+
*
|
|
38
|
+
* Read through `globalThis` rather than the bare identifier so that a runtime
|
|
39
|
+
* without it produces `{}` instead of a `ReferenceError` on import.
|
|
40
|
+
*/
|
|
41
|
+
function processEnv(): Readonly<Record<string, string | undefined>> {
|
|
42
|
+
const global = globalThis as { process?: { env?: Record<string, string | undefined> } }
|
|
43
|
+
return global.process?.env ?? {}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const defaultPathParser: ZenOptions['pathParser'] = {
|
|
47
|
+
parse(path: string) {
|
|
48
|
+
const parsed = parsePath(path)
|
|
49
|
+
return { path: parsed.path, segments: parsed.segments }
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The five-line app:
|
|
55
|
+
*
|
|
56
|
+
* import { zen } from '@erenthedeveloper0/zen'
|
|
57
|
+
* const app = zen()
|
|
58
|
+
* app.get('/', () => 'Hello world')
|
|
59
|
+
* app.listen({ port: 3000 })
|
|
60
|
+
*
|
|
61
|
+
* Two concepts — `app.METHOD(path, handler)`, and *the handler returns the
|
|
62
|
+
* response*. That is one fewer than Express, because there is no response
|
|
63
|
+
* object to learn (§1.2).
|
|
64
|
+
*/
|
|
65
|
+
export function zen<X = {}, C = Record<string, never>>(
|
|
66
|
+
options: ZenAppOptions<C> = {},
|
|
67
|
+
): ZenApp<X & { readonly config: C }> {
|
|
68
|
+
return createApp<X, C>({
|
|
69
|
+
...options,
|
|
70
|
+
router: options.router ?? new ZenRouter(),
|
|
71
|
+
pathParser: options.pathParser ?? defaultPathParser,
|
|
72
|
+
adapter: options.adapter ?? nodeAdapter(),
|
|
73
|
+
// §16.1 layer 6. Explicit `env` — including a list of `.env` sources —
|
|
74
|
+
// always wins; this is only the default nobody should have to write.
|
|
75
|
+
env: options.env ?? processEnv(),
|
|
76
|
+
// §4.5, §12.8 — installed at `listen()`, so an app that is only ever
|
|
77
|
+
// `inject()`ed in a test never touches the process.
|
|
78
|
+
lifecycle: options.lifecycle === false ? undefined : (options.lifecycle ?? processLifecycle()),
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export default zen
|
|
83
|
+
|
|
84
|
+
// Re-export the full public surface so `import { … } from '@erenthedeveloper0/zen'` is enough.
|
|
85
|
+
export * from '@erenthedeveloper0/zen-core'
|
|
86
|
+
export { ZenRouter, parsePath, renderPath, BUILTIN_PARAM_TYPES, analyzeRoutes } from '@erenthedeveloper0/zen-router'
|
|
87
|
+
export { nodeAdapter, NODE_CAPABILITIES, mediaTypeFor, type NodeAdapterOptions } from '@erenthedeveloper0/zen-adapter-node'
|
|
88
|
+
export { processLifecycle, type ProcessLifecycleOptions } from './lifecycle.ts'
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The first-party middleware pack — §24.2's "common middleware" row.
|
|
92
|
+
*
|
|
93
|
+
* Re-exported here and importable from `@erenthedeveloper0/zen-middleware` directly; §21.2's
|
|
94
|
+
* example uses the second form and `examples/middleware` follows it, so both
|
|
95
|
+
* paths stay exercised.
|
|
96
|
+
*
|
|
97
|
+
* Adding this row is what turned `@erenthedeveloper0/zen-core`'s `requestId` — the ULID
|
|
98
|
+
* generator, exported since 0.1 and imported by nothing outside core — into a
|
|
99
|
+
* live collision with the plugin of the same name, where `app.use(requestId())`
|
|
100
|
+
* would have registered a *string* as middleware and failed at compile with a
|
|
101
|
+
* message about neither. The generator is now `generateRequestId`, which is
|
|
102
|
+
* what it always was.
|
|
103
|
+
*/
|
|
104
|
+
export * from '@erenthedeveloper0/zen-middleware'
|
package/src/lifecycle.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { HostControl, HostLifecycle } from '@erenthedeveloper0/zen-core'
|
|
2
|
+
|
|
3
|
+
export interface ProcessLifecycleOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Signals that start a graceful shutdown. `SIGTERM` is what Kubernetes, ECS,
|
|
6
|
+
* systemd and `docker stop` send; `SIGINT` is Ctrl+C.
|
|
7
|
+
*/
|
|
8
|
+
readonly signals?: readonly string[] | undefined
|
|
9
|
+
/**
|
|
10
|
+
* Exit once shutdown finishes — 0 after a signal, 1 after a crash. On by
|
|
11
|
+
* default, because a process whose server has closed and whose pools are
|
|
12
|
+
* disposed has nothing left to do, and one that lingers holds its container.
|
|
13
|
+
*/
|
|
14
|
+
readonly exit?: boolean | undefined
|
|
15
|
+
/**
|
|
16
|
+
* §12.8: on an uncaught exception or unhandled rejection, log it at `fatal`
|
|
17
|
+
* and shut down with exit code 1. On by default.
|
|
18
|
+
*/
|
|
19
|
+
readonly crashes?: boolean | undefined
|
|
20
|
+
/** Called when a shutdown signal arrives, before anything closes. */
|
|
21
|
+
readonly onSignal?: ((signal: string) => void) | undefined
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type Signal = Parameters<NodeJS.Process['on']>[0]
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The Node process's side of the lifecycle — rfcs/0001 §4.5, §12.8.
|
|
28
|
+
*
|
|
29
|
+
* `zen()` installs this by default when the app starts listening. Before it
|
|
30
|
+
* existed, §4.5 described what happens "on SIGTERM" and nothing listened for
|
|
31
|
+
* one: Node's default action ended the process on the spot, so readiness never
|
|
32
|
+
* went red, the drain window never opened, and `onClose` never ran — on every
|
|
33
|
+
* rolling deploy, which is the exact situation §4.5 exists for. Every example
|
|
34
|
+
* in this repository wrote the same four lines to fill the gap, and two did
|
|
35
|
+
* not.
|
|
36
|
+
*
|
|
37
|
+
* - **First signal:** run `app.close()` — readiness drains, the socket
|
|
38
|
+
* closes, in-flight requests finish, `onClose` runs, services dispose — then
|
|
39
|
+
* exit 0.
|
|
40
|
+
* - **Second signal while that is in progress:** exit now. Ctrl+C twice
|
|
41
|
+
* means "stop waiting", and a shutdown stuck behind a hung connection
|
|
42
|
+
* should not be un-killable from a terminal.
|
|
43
|
+
* - **An uncaught exception or unhandled rejection:** log it at `fatal`, then
|
|
44
|
+
* the same graceful shutdown with exit 1. Zen does not keep serving from a
|
|
45
|
+
* process whose state is unknown (§12.8) — that instinct is how corrupted
|
|
46
|
+
* data gets written — but it does let requests already in flight finish.
|
|
47
|
+
*/
|
|
48
|
+
export function processLifecycle(options: ProcessLifecycleOptions = {}): HostLifecycle {
|
|
49
|
+
const signals = options.signals ?? ['SIGTERM', 'SIGINT']
|
|
50
|
+
const exit = options.exit ?? true
|
|
51
|
+
const crashes = options.crashes ?? true
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
install(control: HostControl): () => void {
|
|
55
|
+
const proc = (globalThis as { process?: NodeJS.Process }).process
|
|
56
|
+
if (proc === undefined || typeof proc.on !== 'function') return () => {}
|
|
57
|
+
|
|
58
|
+
let stopping = false
|
|
59
|
+
|
|
60
|
+
const shutdown = (reason: string, code: number): void => {
|
|
61
|
+
if (stopping) {
|
|
62
|
+
control.log.warn({ reason }, 'second shutdown request while shutting down; exiting now')
|
|
63
|
+
proc.exit(code === 0 ? 130 : code)
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
stopping = true
|
|
67
|
+
control.close(reason).then(
|
|
68
|
+
() => { if (exit) proc.exit(code) },
|
|
69
|
+
(error: unknown) => {
|
|
70
|
+
control.log.fatal({ err: error }, 'shutdown failed')
|
|
71
|
+
proc.exit(1)
|
|
72
|
+
},
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const onSignal = (signal: string): void => {
|
|
77
|
+
options.onSignal?.(signal)
|
|
78
|
+
shutdown(signal, 0)
|
|
79
|
+
}
|
|
80
|
+
const onException = (error: unknown): void => {
|
|
81
|
+
control.log.fatal({ err: error }, 'uncaught exception; shutting down')
|
|
82
|
+
shutdown('uncaughtException', 1)
|
|
83
|
+
}
|
|
84
|
+
const onRejection = (reason: unknown): void => {
|
|
85
|
+
control.log.fatal({ err: reason }, 'unhandled promise rejection; shutting down')
|
|
86
|
+
shutdown('unhandledRejection', 1)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for (const signal of signals) proc.on(signal as Signal, onSignal)
|
|
90
|
+
if (crashes) {
|
|
91
|
+
proc.on('uncaughtException', onException)
|
|
92
|
+
proc.on('unhandledRejection', onRejection)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return () => {
|
|
96
|
+
for (const signal of signals) proc.off(signal as Signal, onSignal)
|
|
97
|
+
if (crashes) {
|
|
98
|
+
proc.off('uncaughtException', onException)
|
|
99
|
+
proc.off('unhandledRejection', onRejection)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
}
|