@devindex/api-kit 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -0
- package/env/index.js +50 -0
- package/package.json +3 -1
- package/runtime/index.js +22 -0
package/README.md
CHANGED
|
@@ -425,6 +425,41 @@ Every Redis replica upserts the same scheduler and starts an equivalent Worker.
|
|
|
425
425
|
leader; BullMQ coordinates which Worker receives each occurrence. A new occurrence is produced when
|
|
426
426
|
the previous one starts, so global concurrency serializes slow runs rather than overlapping them.
|
|
427
427
|
|
|
428
|
+
## `./env`
|
|
429
|
+
|
|
430
|
+
`createEnvReader()` reads `process.env` and **collects** what is wrong instead of failing on the
|
|
431
|
+
first problem, so a misconfigured deploy reports every missing or malformed variable in one boot
|
|
432
|
+
rather than one per restart. It does not decide how to fail: `issues()` hands the list back and the
|
|
433
|
+
service merges it with its own cross-field rules.
|
|
434
|
+
|
|
435
|
+
```js
|
|
436
|
+
import { createEnvReader } from '@devindex/api-kit/env';
|
|
437
|
+
|
|
438
|
+
const env = createEnvReader();
|
|
439
|
+
|
|
440
|
+
export const config = Object.freeze({
|
|
441
|
+
port: env.int('PORT', { fallback: 3000 }),
|
|
442
|
+
mongoUri: env.str('MONGO_URI', { required: true }),
|
|
443
|
+
driver: env.oneOf('MESSAGING_DRIVER', ['memory', 'bullmq'], { fallback: 'memory' }),
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
export function assertConfig() {
|
|
447
|
+
const issues = env.issues();
|
|
448
|
+
if (issues.length === 0) return;
|
|
449
|
+
|
|
450
|
+
for (const issue of issues) console.error(`config: ${issue}`);
|
|
451
|
+
process.exit(1);
|
|
452
|
+
}
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
`str`, `int` and `oneOf` take `fallback` (default `null`) and `required` (default `false`), and read
|
|
456
|
+
an empty string as an absent value — a variable left blank in a `.env` is not a value. A rejected
|
|
457
|
+
variable still returns its fallback, so the config object finishes building and `issues()` reports
|
|
458
|
+
everything in one pass.
|
|
459
|
+
|
|
460
|
+
A reader owns its own list, so config split across several modules is just the reader passed to each
|
|
461
|
+
one, and a test builds its own with `createEnvReader({ PORT: '3000' })` without touching the process.
|
|
462
|
+
|
|
428
463
|
## `./runtime`
|
|
429
464
|
|
|
430
465
|
`onShutdown` wires `SIGINT`/`SIGTERM` to a teardown callback and exits — the one
|
|
@@ -449,6 +484,21 @@ A second signal arriving mid-drain is a no-op. A `close` that throws exits `1` a
|
|
|
449
484
|
logging; one that hangs past `timeoutMs` (default `10_000`) force-exits `1` so a stuck
|
|
450
485
|
drain cannot wedge the process. `signals` defaults to `['SIGINT', 'SIGTERM']`.
|
|
451
486
|
|
|
487
|
+
`onFatalError` covers the other exit: an uncaught exception or an unhandled rejection is
|
|
488
|
+
logged as fatal and the process exits `1`.
|
|
489
|
+
|
|
490
|
+
```js
|
|
491
|
+
import { onFatalError } from '@devindex/api-kit/runtime';
|
|
492
|
+
|
|
493
|
+
onFatalError({ logger });
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
It deliberately does not run the shutdown callback. After an uncaught throw the process
|
|
497
|
+
state is undefined, and a teardown running over it can hang or corrupt what it touches —
|
|
498
|
+
exiting fast leaves the restart to the supervisor. The logger is flushed first, so a
|
|
499
|
+
`pretty` transport writing from a worker thread does not lose the line that explains the
|
|
500
|
+
crash.
|
|
501
|
+
|
|
452
502
|
## Tests
|
|
453
503
|
|
|
454
504
|
The default suite exercises every memory path and skips integration tests when Redis is absent:
|
package/env/index.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads environment variables, collecting every problem instead of failing on
|
|
3
|
+
* the first one, so a misconfigured boot reports all of them at once.
|
|
4
|
+
*
|
|
5
|
+
* @param {Record<string, string|undefined>} [source=process.env]
|
|
6
|
+
* @return {{str: Function, int: Function, oneOf: Function, issues: Function}}
|
|
7
|
+
* Frozen reader owning its own issue list.
|
|
8
|
+
*/
|
|
9
|
+
export function createEnvReader(source = process.env) {
|
|
10
|
+
const issues = [];
|
|
11
|
+
|
|
12
|
+
// An empty string is a variable someone left blank in a .env, not a value.
|
|
13
|
+
function read(name, required) {
|
|
14
|
+
const value = source[name];
|
|
15
|
+
if (value === undefined || value === '') {
|
|
16
|
+
if (required) issues.push(`${name} is required`);
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function str(name, { fallback = null, required = false } = {}) {
|
|
23
|
+
return read(name, required) ?? fallback;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function int(name, { fallback = null, required = false } = {}) {
|
|
27
|
+
const raw = read(name, required);
|
|
28
|
+
if (raw === null) return fallback;
|
|
29
|
+
const value = Number.parseInt(raw, 10);
|
|
30
|
+
if (Number.isNaN(value)) {
|
|
31
|
+
issues.push(`${name} must be an integer`);
|
|
32
|
+
return fallback;
|
|
33
|
+
}
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function oneOf(name, values, { fallback = null, required = false } = {}) {
|
|
38
|
+
const raw = read(name, required);
|
|
39
|
+
if (raw === null) return fallback;
|
|
40
|
+
if (!values.includes(raw)) {
|
|
41
|
+
issues.push(`${name} must be one of ${values.join(', ')}`);
|
|
42
|
+
return fallback;
|
|
43
|
+
}
|
|
44
|
+
return raw;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// A copy: the caller merges these with its own cross-field checks, and must
|
|
48
|
+
// not be able to edit the reader's list while doing it.
|
|
49
|
+
return Object.freeze({ str, int, oneOf, issues: () => [...issues] });
|
|
50
|
+
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@devindex/api-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Building blocks for Fastify services: typed domain errors, HTTP plugins, logging and background runtime",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
"./context": "./context/index.js",
|
|
8
|
+
"./env": "./env/index.js",
|
|
8
9
|
"./errors": "./errors/index.js",
|
|
9
10
|
"./events": "./events/index.js",
|
|
10
11
|
"./http": "./http/index.js",
|
|
@@ -16,6 +17,7 @@
|
|
|
16
17
|
},
|
|
17
18
|
"files": [
|
|
18
19
|
"context",
|
|
20
|
+
"env",
|
|
19
21
|
"errors",
|
|
20
22
|
"events",
|
|
21
23
|
"http",
|
package/runtime/index.js
CHANGED
|
@@ -35,3 +35,25 @@ export function onShutdown(close, { signals = ['SIGINT', 'SIGTERM'], timeoutMs =
|
|
|
35
35
|
process.once(signal, () => handle(signal));
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Logs an uncaught exception or unhandled rejection as fatal, then exits 1.
|
|
41
|
+
*
|
|
42
|
+
* @param {object} [options]
|
|
43
|
+
* @param {{ fatal: Function, flush?: Function }} [options.logger] - Logs the error before exiting.
|
|
44
|
+
*/
|
|
45
|
+
export function onFatalError({ logger } = {}) {
|
|
46
|
+
// No teardown here, unlike `onShutdown`: after an uncaught throw the process
|
|
47
|
+
// state is undefined, and a `close` running over it can hang or corrupt what
|
|
48
|
+
// it touches. Exiting fast leaves the restart to the supervisor.
|
|
49
|
+
const handle = (event) => (error) => {
|
|
50
|
+
logger?.fatal({ err: error }, event);
|
|
51
|
+
// A pino transport writes from a worker thread, so exiting on the next line
|
|
52
|
+
// would drop the very line explaining why the process died.
|
|
53
|
+
logger?.flush?.();
|
|
54
|
+
process.exit(1);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
process.on('uncaughtException', handle('uncaught exception'));
|
|
58
|
+
process.on('unhandledRejection', handle('unhandled rejection'));
|
|
59
|
+
}
|