node-cluster-serve 0.9.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.md +18 -0
- package/README.md +141 -0
- package/dist/cli-b8DR3vy7.mjs +251 -0
- package/dist/cli-b8DR3vy7.mjs.map +1 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +9 -0
- package/dist/cli.mjs.map +1 -0
- package/dist/index.d.mts +84 -0
- package/dist/index.mjs +2 -0
- package/package.json +66 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Javier Aguilar (itsjavi.com)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
|
6
|
+
associated documentation files (the "Software"), to deal in the Software without restriction,
|
|
7
|
+
including without limitation the rights to use, copy, modify, merge, publish, distribute,
|
|
8
|
+
sublicense, and/or sell 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 copies or substantial
|
|
12
|
+
portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
|
|
15
|
+
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
16
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES
|
|
17
|
+
OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
18
|
+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# node-cluster-serve
|
|
2
|
+
|
|
3
|
+
Run a Node.js `serve` function in single-process or `node:cluster` mode with graceful shutdown and
|
|
4
|
+
worker restart limits.
|
|
5
|
+
|
|
6
|
+
- **Library:** `runServeFunction` — you pass `serve`; the library does not load a file path.
|
|
7
|
+
- **CLI:** `node-cluster-serve` resolves a module path, `import()`s it, and passes `default` as
|
|
8
|
+
`serve`.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install node-cluster-serve
|
|
14
|
+
# or
|
|
15
|
+
npx nypm add node-cluster-serve
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Quick Start (CLI)
|
|
19
|
+
|
|
20
|
+
Your server module must **default-export** an async `serve` function. Return something with `close`
|
|
21
|
+
(typically an `http.Server`) so SIGTERM/SIGINT can shut down gracefully.
|
|
22
|
+
|
|
23
|
+
`serve` receives a **`ServeContext`**: `Omit<ServeOptions, 'serve'>`. The runner sets defaults for
|
|
24
|
+
`mode`, `port`, `loggerPrefix`, `workerCount`, and shutdown/restart fields. **`host` may be
|
|
25
|
+
`undefined`** if nothing was passed and no local IPv4 was discovered (use a fallback when calling
|
|
26
|
+
`listen`, as below).
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
// e.g. ./server.js (or ./server.ts if your Node/runtime can import TypeScript)
|
|
30
|
+
import { createServer } from 'node:http'
|
|
31
|
+
import type { ServeContext } from 'node-cluster-serve'
|
|
32
|
+
|
|
33
|
+
export default async function serve({ host, port }: ServeContext) {
|
|
34
|
+
const httpServer = createServer((_req, res) => {
|
|
35
|
+
res.end('ok\n')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
await new Promise<void>((resolve, reject) => {
|
|
39
|
+
httpServer.listen({ port, host: host ?? '0.0.0.0' }, (err) => (err ? reject(err) : resolve()))
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
return httpServer
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Run it (use a path Node can `import()` — usually compiled **`.js`**, unless you rely on TypeScript
|
|
47
|
+
stripping or a loader):
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
node-cluster-serve ./dist/server.js --mode production --port 3000 --workerCount 4
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## CLI Arguments
|
|
54
|
+
|
|
55
|
+
```text
|
|
56
|
+
node-cluster-serve <serverModuleFile> [options]
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
- `serverModuleFile` (positional, required): file path or `file:` URL to the server module.
|
|
60
|
+
- `--mode`: `development | production | test` (default: `NODE_ENV`)
|
|
61
|
+
- `--host`: bind host (default: `HOST` env, else first local IPv4)
|
|
62
|
+
- `--port`: bind port (default: `PORT` env, else `3000`)
|
|
63
|
+
- `--loggerPrefix`: log prefix (default: `[node-cluster-serve]`)
|
|
64
|
+
- `--workerCount`: worker count (default: `WORKER_COUNT` or `WEB_CONCURRENCY` env, else `1`)
|
|
65
|
+
- `--shutdownGraceMs`: graceful shutdown timeout in ms (default: `10000`)
|
|
66
|
+
- `--restartWindowMs`: crash restart accounting window in ms (default: `30000`)
|
|
67
|
+
- `--maxRestartsPerWindow`: restart cap per window (default: `10`)
|
|
68
|
+
|
|
69
|
+
The CLI resolves `serverModuleFile` to a `file:` URL and `import()`s it; **`default`** must be the
|
|
70
|
+
`serve` function. Non-`file:` URLs (e.g. `https:`) are rejected.
|
|
71
|
+
|
|
72
|
+
## Programmatic usage
|
|
73
|
+
|
|
74
|
+
Import `serve` yourself and pass it on `options.serve`.
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
import { runServeFunction } from 'node-cluster-serve'
|
|
78
|
+
import serve from './server.js'
|
|
79
|
+
|
|
80
|
+
await runServeFunction({
|
|
81
|
+
serve,
|
|
82
|
+
mode: 'production',
|
|
83
|
+
host: '0.0.0.0',
|
|
84
|
+
port: 3000,
|
|
85
|
+
workerCount: 4,
|
|
86
|
+
loggerPrefix: '[my-app]',
|
|
87
|
+
shutdownGraceMs: 10_000,
|
|
88
|
+
restartWindowMs: 30_000,
|
|
89
|
+
maxRestartsPerWindow: 10,
|
|
90
|
+
})
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
**When the promise resolves:** it does **not** mean the process is about to exit — the HTTP server
|
|
94
|
+
(or whatever you started) keeps the event loop alive.
|
|
95
|
+
|
|
96
|
+
- **Single worker (`workerCount === 1`):** resolves after `serve` has resolved (e.g. after `listen`)
|
|
97
|
+
and signal handlers are registered.
|
|
98
|
+
- **Cluster primary (`workerCount > 1`):** resolves after workers are forked and the primary’s
|
|
99
|
+
handlers are registered; each worker runs `serve` on its own.
|
|
100
|
+
|
|
101
|
+
**Return value:** `runServeFunction` fulfills with the **`ServeContext`** object passed into `serve`
|
|
102
|
+
(same reference).
|
|
103
|
+
|
|
104
|
+
**Cluster mode:** forked workers start a **new Node process** and re-run your **entry file**. That
|
|
105
|
+
entry should call `runServeFunction` again with the same `serve` (usually by importing a shared
|
|
106
|
+
module) so each process has a real function reference.
|
|
107
|
+
|
|
108
|
+
## Embedding the CLI
|
|
109
|
+
|
|
110
|
+
`nodeClusterServeCommand` is exported from `node-cluster-serve` for use with
|
|
111
|
+
[citty](https://github.com/unjs/citty).
|
|
112
|
+
|
|
113
|
+
## Serve function contract
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
type ServeFunction = (context: ServeContext) => Promise<ClosableServer | void>
|
|
117
|
+
type ServeContext = Omit<ServeOptions, 'serve'>
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`ClosableServer` may be:
|
|
121
|
+
|
|
122
|
+
- `{ close(callback) }`, or
|
|
123
|
+
- `{ close(): void | Promise<void> }`, or
|
|
124
|
+
- omitted (`void`) if you handle shutdown yourself.
|
|
125
|
+
|
|
126
|
+
The CLI expects a **`ServeModule`**: `{ default: ServeFunction }`.
|
|
127
|
+
|
|
128
|
+
On SIGTERM/SIGINT, each **worker** awaits `close()` on the value returned from `serve`, then exits.
|
|
129
|
+
The **primary** signals workers to shut down when using cluster mode.
|
|
130
|
+
|
|
131
|
+
## Environment variables
|
|
132
|
+
|
|
133
|
+
- `NODE_ENV`: fallback for `mode`
|
|
134
|
+
- `HOST`: fallback for `host`
|
|
135
|
+
- `PORT`: fallback for `port`
|
|
136
|
+
- `WORKER_COUNT` / `WEB_CONCURRENCY`: fallback for `workerCount` when not set on the CLI or in
|
|
137
|
+
options; if both are set, **`WORKER_COUNT` wins**
|
|
138
|
+
|
|
139
|
+
## License
|
|
140
|
+
|
|
141
|
+
MIT
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import cluster from "node:cluster";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { defineCommand } from "citty";
|
|
5
|
+
//#region src/lib/utils.ts
|
|
6
|
+
function parseNumber(raw) {
|
|
7
|
+
if (raw == null) return void 0;
|
|
8
|
+
let value = Number(raw);
|
|
9
|
+
return Number.isFinite(value) ? value : void 0;
|
|
10
|
+
}
|
|
11
|
+
function parseCliNumberArg(name, value) {
|
|
12
|
+
if (value == null || value === "") return void 0;
|
|
13
|
+
let parsed = Number(value);
|
|
14
|
+
if (!Number.isFinite(parsed)) throw new Error(`Invalid --${name} value: "${value}". Expected a number.`);
|
|
15
|
+
return parsed;
|
|
16
|
+
}
|
|
17
|
+
function parseCliIntegerArg(name, value) {
|
|
18
|
+
let parsed = parseCliNumberArg(name, value);
|
|
19
|
+
if (parsed == null) return void 0;
|
|
20
|
+
if (!Number.isInteger(parsed)) throw new Error(`Invalid --${name} value: "${value}". Expected an integer.`);
|
|
21
|
+
return parsed;
|
|
22
|
+
}
|
|
23
|
+
function parseCliMinIntArg(name, value, min) {
|
|
24
|
+
let parsed = parseCliIntegerArg(name, value);
|
|
25
|
+
if (parsed == null) return void 0;
|
|
26
|
+
if (parsed < min) throw new Error(`Invalid --${name} value: "${value}". Expected an integer >= ${min}.`);
|
|
27
|
+
return parsed;
|
|
28
|
+
}
|
|
29
|
+
function parseCliPortArg(value) {
|
|
30
|
+
let parsed = parseCliMinIntArg("port", value, 1);
|
|
31
|
+
if (parsed == null) return void 0;
|
|
32
|
+
if (parsed > 65535) throw new Error(`Invalid --port value: "${value}". Expected an integer <= 65535.`);
|
|
33
|
+
return parsed;
|
|
34
|
+
}
|
|
35
|
+
function resolveServerModuleFileUrl(input) {
|
|
36
|
+
let url;
|
|
37
|
+
if (input instanceof URL) url = input;
|
|
38
|
+
else if (input.startsWith("file:")) url = new URL(input);
|
|
39
|
+
else if (input.startsWith("/")) url = pathToFileURL(input);
|
|
40
|
+
else url = new URL(input, pathToFileURL(`${process.cwd()}/`));
|
|
41
|
+
if (url.protocol !== "file:") throw new Error(`Module path must resolve to a file: URL (got ${url.protocol}). Remote URLs are not supported.`);
|
|
42
|
+
return url;
|
|
43
|
+
}
|
|
44
|
+
function getHostAddress() {
|
|
45
|
+
if (process.env.HOST) return process.env.HOST;
|
|
46
|
+
return Object.values(os.networkInterfaces()).flat().find((ip) => String(ip?.family).includes("4") && !ip?.internal)?.address;
|
|
47
|
+
}
|
|
48
|
+
function resolveDefaultWorkerCount() {
|
|
49
|
+
return Math.max(1, parseNumber(process.env.WORKER_COUNT) ?? parseNumber(process.env.WEB_CONCURRENCY) ?? 1);
|
|
50
|
+
}
|
|
51
|
+
//#endregion
|
|
52
|
+
//#region src/lib/server.ts
|
|
53
|
+
const DEFAULT_SHUTDOWN_GRACE_MS = 1e4;
|
|
54
|
+
const DEFAULT_RESTART_WINDOW_MS = 3e4;
|
|
55
|
+
const DEFAULT_MAX_RESTARTS_PER_WINDOW = 10;
|
|
56
|
+
function resolveServerMode(mode) {
|
|
57
|
+
const resolved = mode ?? process.env.NODE_ENV;
|
|
58
|
+
if (resolved !== "development" && resolved !== "production" && resolved !== "test") throw new Error("Missing or invalid NODE_ENV/mode. Use development, production, or test.");
|
|
59
|
+
return resolved;
|
|
60
|
+
}
|
|
61
|
+
function logBoundUrl(loggerPrefix, host, port, workerSuffix) {
|
|
62
|
+
const suffix = workerSuffix ?? "";
|
|
63
|
+
if (!host) console.log(`${loggerPrefix} http://localhost:${port}${suffix}`);
|
|
64
|
+
else console.log(`${loggerPrefix} http://localhost:${port} (http://${host}:${port})${suffix}`);
|
|
65
|
+
}
|
|
66
|
+
async function closeServer(server) {
|
|
67
|
+
if (!server?.close) return;
|
|
68
|
+
if (server.close.length > 0) {
|
|
69
|
+
await new Promise((resolve, reject) => {
|
|
70
|
+
server.close((error) => {
|
|
71
|
+
if (error) reject(error);
|
|
72
|
+
else resolve();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
await server.close();
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Runs `options.serve` in the current process, or supervises a cluster when `workerCount > 1`.
|
|
81
|
+
* - **Single worker:** resolves after your `serve` function resolves (e.g. after listen).
|
|
82
|
+
* - **Cluster primary:** resolves after workers are forked and handlers are registered; workers
|
|
83
|
+
* call `serve` on their own afterward.
|
|
84
|
+
* The promise does not wait for process exit.
|
|
85
|
+
*/
|
|
86
|
+
async function runServeFunction(options) {
|
|
87
|
+
if (typeof options.serve !== "function") throw new Error("options.serve must be a function");
|
|
88
|
+
const mode = resolveServerMode(options.mode);
|
|
89
|
+
const host = options.host ?? getHostAddress();
|
|
90
|
+
const port = options.port ?? parseNumber(process.env.PORT) ?? 3e3;
|
|
91
|
+
const loggerPrefix = options.loggerPrefix ?? "[node-cluster-serve]";
|
|
92
|
+
const shutdownGraceMs = options.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS;
|
|
93
|
+
const restartWindowMs = options.restartWindowMs ?? DEFAULT_RESTART_WINDOW_MS;
|
|
94
|
+
const maxRestartsPerWindow = options.maxRestartsPerWindow ?? DEFAULT_MAX_RESTARTS_PER_WINDOW;
|
|
95
|
+
const workerCount = Math.max(1, options.workerCount ?? resolveDefaultWorkerCount());
|
|
96
|
+
const resolvedContext = {
|
|
97
|
+
mode,
|
|
98
|
+
host,
|
|
99
|
+
port,
|
|
100
|
+
loggerPrefix,
|
|
101
|
+
workerCount,
|
|
102
|
+
shutdownGraceMs,
|
|
103
|
+
restartWindowMs,
|
|
104
|
+
maxRestartsPerWindow
|
|
105
|
+
};
|
|
106
|
+
async function startWorker() {
|
|
107
|
+
const server = await options.serve(resolvedContext);
|
|
108
|
+
logBoundUrl(loggerPrefix, host, port, cluster.isWorker ? ` (worker ${cluster.worker?.id})` : void 0);
|
|
109
|
+
let shuttingDown = false;
|
|
110
|
+
const shutdown = async () => {
|
|
111
|
+
if (shuttingDown) return;
|
|
112
|
+
shuttingDown = true;
|
|
113
|
+
const forceTimeout = setTimeout(() => {
|
|
114
|
+
console.error(`${loggerPrefix} worker forced shutdown timeout reached`);
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}, shutdownGraceMs);
|
|
117
|
+
try {
|
|
118
|
+
await closeServer(server);
|
|
119
|
+
clearTimeout(forceTimeout);
|
|
120
|
+
process.exit(0);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
clearTimeout(forceTimeout);
|
|
123
|
+
console.error(error);
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
process.once("SIGTERM", () => void shutdown());
|
|
128
|
+
process.once("SIGINT", () => void shutdown());
|
|
129
|
+
}
|
|
130
|
+
if (workerCount > 1 && cluster.isPrimary) {
|
|
131
|
+
console.log(`${loggerPrefix} starting cluster with ${workerCount} workers`);
|
|
132
|
+
let shuttingDown = false;
|
|
133
|
+
let restartTimestamps = [];
|
|
134
|
+
const livingWorkerCount = () => Object.values(cluster.workers ?? {}).filter((w) => w && !w.isDead()).length;
|
|
135
|
+
const spawnWorker = () => {
|
|
136
|
+
const now = Date.now();
|
|
137
|
+
restartTimestamps = restartTimestamps.filter((ts) => now - ts < restartWindowMs);
|
|
138
|
+
if (restartTimestamps.length >= maxRestartsPerWindow) {
|
|
139
|
+
console.error(`${loggerPrefix} restart rate exceeded, refusing to fork more workers`);
|
|
140
|
+
if (livingWorkerCount() === 0) {
|
|
141
|
+
console.error(`${loggerPrefix} no workers left; exiting primary`);
|
|
142
|
+
process.exit(1);
|
|
143
|
+
}
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
restartTimestamps.push(now);
|
|
147
|
+
cluster.fork();
|
|
148
|
+
};
|
|
149
|
+
for (let i = 0; i < workerCount; i++) spawnWorker();
|
|
150
|
+
cluster.on("exit", (worker, code, signal) => {
|
|
151
|
+
console.error(`${loggerPrefix} worker ${worker.process.pid} exited (code=${code} signal=${signal})`);
|
|
152
|
+
if (shuttingDown) {
|
|
153
|
+
if (livingWorkerCount() === 0) process.exit(0);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
spawnWorker();
|
|
157
|
+
});
|
|
158
|
+
const shutdownPrimary = async () => {
|
|
159
|
+
if (shuttingDown) return;
|
|
160
|
+
shuttingDown = true;
|
|
161
|
+
console.log(`${loggerPrefix} primary shutting down cluster`);
|
|
162
|
+
for (const worker of Object.values(cluster.workers ?? {})) worker?.process.kill("SIGTERM");
|
|
163
|
+
setTimeout(() => {
|
|
164
|
+
for (const worker of Object.values(cluster.workers ?? {})) worker?.process.kill("SIGKILL");
|
|
165
|
+
process.exit(1);
|
|
166
|
+
}, shutdownGraceMs).unref();
|
|
167
|
+
};
|
|
168
|
+
process.once("SIGTERM", () => void shutdownPrimary());
|
|
169
|
+
process.once("SIGINT", () => void shutdownPrimary());
|
|
170
|
+
return resolvedContext;
|
|
171
|
+
}
|
|
172
|
+
await startWorker();
|
|
173
|
+
return resolvedContext;
|
|
174
|
+
}
|
|
175
|
+
//#endregion
|
|
176
|
+
//#region src/lib/cli.ts
|
|
177
|
+
const nodeClusterServeCommand = defineCommand({
|
|
178
|
+
meta: {
|
|
179
|
+
name: "node-cluster-serve",
|
|
180
|
+
description: "Run a server module in single-process or cluster mode."
|
|
181
|
+
},
|
|
182
|
+
args: {
|
|
183
|
+
serverModuleFile: {
|
|
184
|
+
type: "positional",
|
|
185
|
+
description: "Path or file URL to a server module that exports a default serve function.",
|
|
186
|
+
required: true,
|
|
187
|
+
valueHint: "file"
|
|
188
|
+
},
|
|
189
|
+
mode: {
|
|
190
|
+
type: "enum",
|
|
191
|
+
description: "Server mode (defaults to NODE_ENV).",
|
|
192
|
+
options: [
|
|
193
|
+
"development",
|
|
194
|
+
"production",
|
|
195
|
+
"test"
|
|
196
|
+
]
|
|
197
|
+
},
|
|
198
|
+
host: {
|
|
199
|
+
type: "string",
|
|
200
|
+
description: "Host to bind to (defaults to HOST or first local IPv4)."
|
|
201
|
+
},
|
|
202
|
+
port: {
|
|
203
|
+
type: "string",
|
|
204
|
+
description: "Port to bind to (defaults to PORT or 3000).",
|
|
205
|
+
valueHint: "number"
|
|
206
|
+
},
|
|
207
|
+
loggerPrefix: {
|
|
208
|
+
type: "string",
|
|
209
|
+
description: "Log prefix for startup and lifecycle messages."
|
|
210
|
+
},
|
|
211
|
+
workerCount: {
|
|
212
|
+
type: "string",
|
|
213
|
+
description: "Server worker count (defaults to WORKER_COUNT, WEB_CONCURRENCY or 1).",
|
|
214
|
+
valueHint: "number"
|
|
215
|
+
},
|
|
216
|
+
shutdownGraceMs: {
|
|
217
|
+
type: "string",
|
|
218
|
+
description: "Graceful shutdown timeout in milliseconds.",
|
|
219
|
+
valueHint: "number"
|
|
220
|
+
},
|
|
221
|
+
restartWindowMs: {
|
|
222
|
+
type: "string",
|
|
223
|
+
description: "Crash restart accounting window in milliseconds.",
|
|
224
|
+
valueHint: "number"
|
|
225
|
+
},
|
|
226
|
+
maxRestartsPerWindow: {
|
|
227
|
+
type: "string",
|
|
228
|
+
description: "Maximum worker restarts allowed per restart window.",
|
|
229
|
+
valueHint: "number"
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
async run({ args }) {
|
|
233
|
+
const serve = (await import(resolveServerModuleFileUrl(String(args.serverModuleFile)).href)).default;
|
|
234
|
+
if (typeof serve !== "function") throw new Error("Server module must default-export a serve function");
|
|
235
|
+
await runServeFunction({
|
|
236
|
+
serve,
|
|
237
|
+
mode: args.mode,
|
|
238
|
+
host: args.host,
|
|
239
|
+
port: parseCliPortArg(args.port),
|
|
240
|
+
loggerPrefix: args.loggerPrefix,
|
|
241
|
+
workerCount: parseCliMinIntArg("workerCount", args.workerCount, 1),
|
|
242
|
+
shutdownGraceMs: parseCliMinIntArg("shutdownGraceMs", args.shutdownGraceMs, 1),
|
|
243
|
+
restartWindowMs: parseCliMinIntArg("restartWindowMs", args.restartWindowMs, 1),
|
|
244
|
+
maxRestartsPerWindow: parseCliMinIntArg("maxRestartsPerWindow", args.maxRestartsPerWindow, 1)
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
//#endregion
|
|
249
|
+
export { resolveServerMode as n, runServeFunction as r, nodeClusterServeCommand as t };
|
|
250
|
+
|
|
251
|
+
//# sourceMappingURL=cli-b8DR3vy7.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli-b8DR3vy7.mjs","names":[],"sources":["../src/lib/utils.ts","../src/lib/server.ts","../src/lib/cli.ts"],"sourcesContent":["import os from 'node:os'\nimport { pathToFileURL } from 'node:url'\n\nexport function parseNumber(raw?: string): number | undefined {\n if (raw == null) return undefined\n let value = Number(raw)\n return Number.isFinite(value) ? value : undefined\n}\n\nexport function parseCliNumberArg(name: string, value?: string): number | undefined {\n if (value == null || value === '') return undefined\n let parsed = Number(value)\n if (!Number.isFinite(parsed)) {\n throw new Error(`Invalid --${name} value: \"${value}\". Expected a number.`)\n }\n return parsed\n}\n\nexport function parseCliIntegerArg(name: string, value?: string): number | undefined {\n let parsed = parseCliNumberArg(name, value)\n if (parsed == null) return undefined\n if (!Number.isInteger(parsed)) {\n throw new Error(`Invalid --${name} value: \"${value}\". Expected an integer.`)\n }\n return parsed\n}\n\nexport function parseCliMinIntArg(\n name: string,\n value: string | undefined,\n min: number,\n): number | undefined {\n let parsed = parseCliIntegerArg(name, value)\n if (parsed == null) return undefined\n if (parsed < min) {\n throw new Error(`Invalid --${name} value: \"${value}\". Expected an integer >= ${min}.`)\n }\n return parsed\n}\n\nexport function parseCliPortArg(value?: string): number | undefined {\n let parsed = parseCliMinIntArg('port', value, 1)\n if (parsed == null) return undefined\n if (parsed > 65535) {\n throw new Error(`Invalid --port value: \"${value}\". Expected an integer <= 65535.`)\n }\n return parsed\n}\n\nexport function resolveServerModuleFileUrl(input: string | URL): URL {\n let url: URL\n if (input instanceof URL) {\n url = input\n } else if (input.startsWith('file:')) {\n url = new URL(input)\n } else if (input.startsWith('/')) {\n url = pathToFileURL(input)\n } else {\n url = new URL(input, pathToFileURL(`${process.cwd()}/`))\n }\n\n if (url.protocol !== 'file:') {\n throw new Error(\n `Module path must resolve to a file: URL (got ${url.protocol}). Remote URLs are not supported.`,\n )\n }\n return url\n}\n\nexport function getHostAddress(): string | undefined {\n if (process.env.HOST) return process.env.HOST\n return Object.values(os.networkInterfaces())\n .flat()\n .find((ip) => String(ip?.family).includes('4') && !ip?.internal)?.address\n}\n\nexport function resolveDefaultWorkerCount(): number {\n return Math.max(\n 1,\n parseNumber(process.env.WORKER_COUNT) ?? parseNumber(process.env.WEB_CONCURRENCY) ?? 1,\n )\n}\n","import cluster from 'node:cluster'\nimport { getHostAddress, parseNumber, resolveDefaultWorkerCount } from './utils.ts'\n\nconst DEFAULT_SHUTDOWN_GRACE_MS = 10_000\nconst DEFAULT_RESTART_WINDOW_MS = 30_000\nconst DEFAULT_MAX_RESTARTS_PER_WINDOW = 10\n\nexport type ClosableServer = {\n close?: ((callback: (error?: unknown) => void) => void) | (() => void | Promise<void>)\n}\n\nexport type ServerMode = 'development' | 'production' | 'test'\nexport type ServeContext = Omit<ServeOptions, 'serve'>\nexport type ServeFunction = (context: Omit<ServeOptions, 'serve'>) => Promise<ClosableServer | void>\n\nexport interface ServeOptions {\n serve: ServeFunction\n mode?: ServerMode\n host?: string\n port?: number\n loggerPrefix?: string\n workerCount?: number\n shutdownGraceMs?: number\n restartWindowMs?: number\n maxRestartsPerWindow?: number\n}\n\n/** Module shape the CLI loads: `default` must be a `ServeFunction`. */\nexport type ServeModule = {\n default: ServeFunction\n}\n\nexport function resolveServerMode(mode?: string): ServerMode {\n const resolved = mode ?? process.env.NODE_ENV\n if (resolved !== 'development' && resolved !== 'production' && resolved !== 'test') {\n throw new Error('Missing or invalid NODE_ENV/mode. Use development, production, or test.')\n }\n return resolved\n}\n\nfunction logBoundUrl(\n loggerPrefix: string,\n host: string | undefined,\n port: number,\n workerSuffix?: string,\n) {\n const suffix = workerSuffix ?? ''\n if (!host) {\n console.log(`${loggerPrefix} http://localhost:${port}${suffix}`)\n } else {\n console.log(`${loggerPrefix} http://localhost:${port} (http://${host}:${port})${suffix}`)\n }\n}\n\nasync function closeServer(server: ClosableServer | void) {\n if (!server?.close) return\n if (server.close.length > 0) {\n await new Promise<void>((resolve, reject) => {\n ;(server.close as (callback: (error?: unknown) => void) => void)((error?: unknown) => {\n if (error) reject(error)\n else resolve()\n })\n })\n return\n }\n await (server.close as () => void | Promise<void>)()\n}\n\n/**\n * Runs `options.serve` in the current process, or supervises a cluster when `workerCount > 1`.\n * - **Single worker:** resolves after your `serve` function resolves (e.g. after listen).\n * - **Cluster primary:** resolves after workers are forked and handlers are registered; workers\n * call `serve` on their own afterward.\n * The promise does not wait for process exit.\n */\nexport async function runServeFunction(options: ServeOptions): Promise<ServeContext> {\n if (typeof options.serve !== 'function') {\n throw new Error('options.serve must be a function')\n }\n\n const mode = resolveServerMode(options.mode)\n const host = options.host ?? getHostAddress()\n const port = options.port ?? parseNumber(process.env.PORT) ?? 3000\n const loggerPrefix = options.loggerPrefix ?? '[node-cluster-serve]'\n const shutdownGraceMs = options.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS\n const restartWindowMs = options.restartWindowMs ?? DEFAULT_RESTART_WINDOW_MS\n const maxRestartsPerWindow = options.maxRestartsPerWindow ?? DEFAULT_MAX_RESTARTS_PER_WINDOW\n const workerCount = Math.max(1, options.workerCount ?? resolveDefaultWorkerCount())\n\n const resolvedContext: ServeContext = {\n mode,\n host,\n port,\n loggerPrefix,\n workerCount,\n shutdownGraceMs,\n restartWindowMs,\n maxRestartsPerWindow,\n }\n\n async function startWorker() {\n const server = await options.serve(resolvedContext)\n logBoundUrl(\n loggerPrefix,\n host,\n port,\n cluster.isWorker ? ` (worker ${cluster.worker?.id})` : undefined,\n )\n\n let shuttingDown = false\n const shutdown = async () => {\n if (shuttingDown) return\n shuttingDown = true\n const forceTimeout = setTimeout(() => {\n console.error(`${loggerPrefix} worker forced shutdown timeout reached`)\n process.exit(1)\n }, shutdownGraceMs)\n\n try {\n await closeServer(server)\n clearTimeout(forceTimeout)\n process.exit(0)\n } catch (error) {\n clearTimeout(forceTimeout)\n console.error(error)\n process.exit(1)\n }\n }\n\n process.once('SIGTERM', () => void shutdown())\n process.once('SIGINT', () => void shutdown())\n }\n\n if (workerCount > 1 && cluster.isPrimary) {\n console.log(`${loggerPrefix} starting cluster with ${workerCount} workers`)\n let shuttingDown = false\n let restartTimestamps: number[] = []\n\n const livingWorkerCount = () =>\n Object.values(cluster.workers ?? {}).filter((w) => w && !w.isDead()).length\n\n const spawnWorker = () => {\n const now = Date.now()\n restartTimestamps = restartTimestamps.filter((ts) => now - ts < restartWindowMs)\n if (restartTimestamps.length >= maxRestartsPerWindow) {\n console.error(`${loggerPrefix} restart rate exceeded, refusing to fork more workers`)\n if (livingWorkerCount() === 0) {\n console.error(`${loggerPrefix} no workers left; exiting primary`)\n process.exit(1)\n }\n return\n }\n restartTimestamps.push(now)\n cluster.fork()\n }\n\n for (let i = 0; i < workerCount; i++) {\n spawnWorker()\n }\n\n cluster.on('exit', (worker, code, signal) => {\n console.error(\n `${loggerPrefix} worker ${worker.process.pid} exited (code=${code} signal=${signal})`,\n )\n if (shuttingDown) {\n if (livingWorkerCount() === 0) {\n process.exit(0)\n }\n return\n }\n spawnWorker()\n })\n\n const shutdownPrimary = async () => {\n if (shuttingDown) return\n shuttingDown = true\n console.log(`${loggerPrefix} primary shutting down cluster`)\n\n for (const worker of Object.values(cluster.workers ?? {})) {\n worker?.process.kill('SIGTERM')\n }\n\n setTimeout(() => {\n for (const worker of Object.values(cluster.workers ?? {})) {\n worker?.process.kill('SIGKILL')\n }\n process.exit(1)\n }, shutdownGraceMs).unref()\n }\n\n process.once('SIGTERM', () => void shutdownPrimary())\n process.once('SIGINT', () => void shutdownPrimary())\n return resolvedContext\n }\n\n await startWorker()\n\n return resolvedContext\n}\n","import { defineCommand } from 'citty'\nimport {\n runServeFunction,\n type ServeFunction,\n type ServeModule,\n type ServerMode,\n} from './server.ts'\nimport { parseCliMinIntArg, parseCliPortArg, resolveServerModuleFileUrl } from './utils.ts'\n\nexport const nodeClusterServeCommand = defineCommand({\n meta: {\n name: 'node-cluster-serve',\n description: 'Run a server module in single-process or cluster mode.',\n },\n args: {\n serverModuleFile: {\n type: 'positional',\n description: 'Path or file URL to a server module that exports a default serve function.',\n required: true,\n valueHint: 'file',\n },\n mode: {\n type: 'enum',\n description: 'Server mode (defaults to NODE_ENV).',\n options: ['development', 'production', 'test'],\n },\n host: {\n type: 'string',\n description: 'Host to bind to (defaults to HOST or first local IPv4).',\n },\n port: {\n type: 'string',\n description: 'Port to bind to (defaults to PORT or 3000).',\n valueHint: 'number',\n },\n loggerPrefix: {\n type: 'string',\n description: 'Log prefix for startup and lifecycle messages.',\n },\n workerCount: {\n type: 'string',\n description: 'Server worker count (defaults to WORKER_COUNT, WEB_CONCURRENCY or 1).',\n valueHint: 'number',\n },\n shutdownGraceMs: {\n type: 'string',\n description: 'Graceful shutdown timeout in milliseconds.',\n valueHint: 'number',\n },\n restartWindowMs: {\n type: 'string',\n description: 'Crash restart accounting window in milliseconds.',\n valueHint: 'number',\n },\n maxRestartsPerWindow: {\n type: 'string',\n description: 'Maximum worker restarts allowed per restart window.',\n valueHint: 'number',\n },\n },\n async run({ args }) {\n const moduleUrl = resolveServerModuleFileUrl(String(args.serverModuleFile))\n const mod = (await import(moduleUrl.href)) as ServeModule\n const serve = mod.default\n if (typeof serve !== 'function') {\n throw new Error('Server module must default-export a serve function')\n }\n\n await runServeFunction({\n serve: serve as ServeFunction,\n mode: args.mode as ServerMode | undefined,\n host: args.host,\n port: parseCliPortArg(args.port),\n loggerPrefix: args.loggerPrefix,\n workerCount: parseCliMinIntArg('workerCount', args.workerCount, 1),\n shutdownGraceMs: parseCliMinIntArg('shutdownGraceMs', args.shutdownGraceMs, 1),\n restartWindowMs: parseCliMinIntArg('restartWindowMs', args.restartWindowMs, 1),\n maxRestartsPerWindow: parseCliMinIntArg('maxRestartsPerWindow', args.maxRestartsPerWindow, 1),\n })\n },\n})\n"],"mappings":"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"}
|
package/dist/cli.d.mts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
package/dist/cli.mjs
ADDED
package/dist/cli.mjs.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli.mjs","names":[],"sources":["../src/cli.ts"],"sourcesContent":["#!/usr/bin/env node\n\nimport { runMain } from 'citty'\nimport { nodeClusterServeCommand } from './lib/cli.ts'\n\nawait runMain(nodeClusterServeCommand)\n"],"mappings":";;;;AAKA,MAAM,QAAQ,wBAAwB"}
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import * as _$citty from "citty";
|
|
2
|
+
|
|
3
|
+
//#region src/lib/server.d.ts
|
|
4
|
+
type ClosableServer = {
|
|
5
|
+
close?: ((callback: (error?: unknown) => void) => void) | (() => void | Promise<void>);
|
|
6
|
+
};
|
|
7
|
+
type ServerMode = 'development' | 'production' | 'test';
|
|
8
|
+
type ServeContext = Omit<ServeOptions, 'serve'>;
|
|
9
|
+
type ServeFunction = (context: Omit<ServeOptions, 'serve'>) => Promise<ClosableServer | void>;
|
|
10
|
+
interface ServeOptions {
|
|
11
|
+
serve: ServeFunction;
|
|
12
|
+
mode?: ServerMode;
|
|
13
|
+
host?: string;
|
|
14
|
+
port?: number;
|
|
15
|
+
loggerPrefix?: string;
|
|
16
|
+
workerCount?: number;
|
|
17
|
+
shutdownGraceMs?: number;
|
|
18
|
+
restartWindowMs?: number;
|
|
19
|
+
maxRestartsPerWindow?: number;
|
|
20
|
+
}
|
|
21
|
+
/** Module shape the CLI loads: `default` must be a `ServeFunction`. */
|
|
22
|
+
type ServeModule = {
|
|
23
|
+
default: ServeFunction;
|
|
24
|
+
};
|
|
25
|
+
declare function resolveServerMode(mode?: string): ServerMode;
|
|
26
|
+
/**
|
|
27
|
+
* Runs `options.serve` in the current process, or supervises a cluster when `workerCount > 1`.
|
|
28
|
+
* - **Single worker:** resolves after your `serve` function resolves (e.g. after listen).
|
|
29
|
+
* - **Cluster primary:** resolves after workers are forked and handlers are registered; workers
|
|
30
|
+
* call `serve` on their own afterward.
|
|
31
|
+
* The promise does not wait for process exit.
|
|
32
|
+
*/
|
|
33
|
+
declare function runServeFunction(options: ServeOptions): Promise<ServeContext>;
|
|
34
|
+
//#endregion
|
|
35
|
+
//#region src/lib/cli.d.ts
|
|
36
|
+
declare const nodeClusterServeCommand: _$citty.CommandDef<{
|
|
37
|
+
readonly serverModuleFile: {
|
|
38
|
+
readonly type: "positional";
|
|
39
|
+
readonly description: "Path or file URL to a server module that exports a default serve function.";
|
|
40
|
+
readonly required: true;
|
|
41
|
+
readonly valueHint: "file";
|
|
42
|
+
};
|
|
43
|
+
readonly mode: {
|
|
44
|
+
readonly type: "enum";
|
|
45
|
+
readonly description: "Server mode (defaults to NODE_ENV).";
|
|
46
|
+
readonly options: ["development", "production", "test"];
|
|
47
|
+
};
|
|
48
|
+
readonly host: {
|
|
49
|
+
readonly type: "string";
|
|
50
|
+
readonly description: "Host to bind to (defaults to HOST or first local IPv4).";
|
|
51
|
+
};
|
|
52
|
+
readonly port: {
|
|
53
|
+
readonly type: "string";
|
|
54
|
+
readonly description: "Port to bind to (defaults to PORT or 3000).";
|
|
55
|
+
readonly valueHint: "number";
|
|
56
|
+
};
|
|
57
|
+
readonly loggerPrefix: {
|
|
58
|
+
readonly type: "string";
|
|
59
|
+
readonly description: "Log prefix for startup and lifecycle messages.";
|
|
60
|
+
};
|
|
61
|
+
readonly workerCount: {
|
|
62
|
+
readonly type: "string";
|
|
63
|
+
readonly description: "Server worker count (defaults to WORKER_COUNT, WEB_CONCURRENCY or 1).";
|
|
64
|
+
readonly valueHint: "number";
|
|
65
|
+
};
|
|
66
|
+
readonly shutdownGraceMs: {
|
|
67
|
+
readonly type: "string";
|
|
68
|
+
readonly description: "Graceful shutdown timeout in milliseconds.";
|
|
69
|
+
readonly valueHint: "number";
|
|
70
|
+
};
|
|
71
|
+
readonly restartWindowMs: {
|
|
72
|
+
readonly type: "string";
|
|
73
|
+
readonly description: "Crash restart accounting window in milliseconds.";
|
|
74
|
+
readonly valueHint: "number";
|
|
75
|
+
};
|
|
76
|
+
readonly maxRestartsPerWindow: {
|
|
77
|
+
readonly type: "string";
|
|
78
|
+
readonly description: "Maximum worker restarts allowed per restart window.";
|
|
79
|
+
readonly valueHint: "number";
|
|
80
|
+
};
|
|
81
|
+
}>;
|
|
82
|
+
//#endregion
|
|
83
|
+
export { ClosableServer, ServeContext, ServeFunction, ServeModule, ServeOptions, ServerMode, nodeClusterServeCommand, resolveServerMode, runServeFunction };
|
|
84
|
+
//# sourceMappingURL=index.d.mts.map
|
package/dist/index.mjs
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "node-cluster-serve",
|
|
3
|
+
"version": "0.9.0",
|
|
4
|
+
"description": "Run a Node.js serve() function with optional node:cluster workers, graceful shutdown, and worker restart limits.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"graceful-shutdown",
|
|
7
|
+
"http-server",
|
|
8
|
+
"node-cluster",
|
|
9
|
+
"process-supervisor"
|
|
10
|
+
],
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/itsjavi/node-cluster-serve/issues"
|
|
13
|
+
},
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/itsjavi/node-cluster-serve.git"
|
|
18
|
+
},
|
|
19
|
+
"bin": {
|
|
20
|
+
"node-cluster-serve": "./dist/cli.mjs"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist/",
|
|
24
|
+
"README.md"
|
|
25
|
+
],
|
|
26
|
+
"type": "module",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"node": {
|
|
30
|
+
"types": "./dist/index.d.mts",
|
|
31
|
+
"module-sync": "./dist/index.mjs"
|
|
32
|
+
},
|
|
33
|
+
"import": {
|
|
34
|
+
"types": "./dist/index.d.mts",
|
|
35
|
+
"default": "./dist/index.mjs"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "tsdown",
|
|
41
|
+
"postbuild": "pnpm dlx publint",
|
|
42
|
+
"format": "oxfmt --write .",
|
|
43
|
+
"format:check": "oxfmt --check .",
|
|
44
|
+
"lint": "pnpm typecheck",
|
|
45
|
+
"lint:circular": "pnpm dlx madge --circular src/index.ts src/cli.ts",
|
|
46
|
+
"prepublishOnly": "pnpm build",
|
|
47
|
+
"test": "vitest --run",
|
|
48
|
+
"test:coverage": "vitest --run --coverage",
|
|
49
|
+
"typecheck": "tsc"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"citty": "^0.2.2"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@types/node": "^25.5.2",
|
|
56
|
+
"@vitest/coverage-v8": "^4.1.2",
|
|
57
|
+
"oxfmt": "^0.43.0",
|
|
58
|
+
"publint": "^0.3.18",
|
|
59
|
+
"tsdown": "^0.21.7",
|
|
60
|
+
"typescript": "^6.0.2",
|
|
61
|
+
"vitest": "^4.1.2"
|
|
62
|
+
},
|
|
63
|
+
"engines": {
|
|
64
|
+
"node": ">=22.0.0"
|
|
65
|
+
}
|
|
66
|
+
}
|