@devindex/api-kit 0.1.0 → 0.3.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 CHANGED
@@ -103,7 +103,8 @@ The status is the error's own `status` when it has one, otherwise `STATUS_BY_COD
103
103
 
104
104
  Inbound `x-request-id` is reused only when it is a valid UUID, otherwise a fresh v4 is generated; the
105
105
  id is always echoed back. `schema.js` ships ODM-agnostic JSON-Schema helpers — `objectSchema`,
106
- `stringSchema`, `pageQuery`, `email`, `dateTime`, `dateKey`, `clock`.
106
+ `stringSchema`, `pageQuery`, `pageResponse`, `email`, `dateTime`, `dateKey`, `clock` (see
107
+ [Pagination](#pagination)).
107
108
 
108
109
  ### CORS and security headers
109
110
 
@@ -145,6 +146,54 @@ await createApp({ helmet: { contentSecurityPolicy: { useDefaults: true } }, rout
145
146
  Anything else — rate limits, compression — still goes through `plugins`, which registers after these
146
147
  and before the routes.
147
148
 
149
+ ### Pagination
150
+
151
+ Offset pagination in two shapes, `{ items, hasMore }` and `{ items, hasMore, total }`, over one rule:
152
+ **the query fetches `limit + 1` rows** and `paginate` slices the extra one off. The kit never runs the
153
+ query — `skip`/`limit` belong to Mongoose, `offset`/`limit` to a SQL builder, a cursor to an upstream
154
+ API — so it only does the arithmetic and the shape, and works with all of them.
155
+
156
+ ```js
157
+ import { paginate } from '@devindex/api-kit/http';
158
+
159
+ const rows = await Order.find(filter).skip(offset).limit(limit + 1);
160
+ return paginate(rows, limit); // { items, hasMore }
161
+ ```
162
+
163
+ A resource that really needs the count passes it as the third argument, and gets `total` in the
164
+ response:
165
+
166
+ ```js
167
+ const [rows, total] = await Promise.all([
168
+ Order.find(filter).skip(offset).limit(limit + 1),
169
+ Order.countDocuments(filter),
170
+ ]);
171
+ return paginate(rows, limit, total); // { items, hasMore, total }
172
+ ```
173
+
174
+ `hasMore` always comes from the extra row, never from `total`. Deriving it from the count would make
175
+ it depend on two queries that can disagree — a document inserted between them, and the flag promises
176
+ a page that is not there.
177
+
178
+ `pageQuery` and `pageResponse` are the two ends of the contract. The response schema is not optional
179
+ decoration: Fastify strips whatever it does not declare, so a missing `hasMore` there silently
180
+ disappears from a correct payload.
181
+
182
+ ```js
183
+ import { pageQuery, pageResponse } from '@devindex/api-kit/http';
184
+
185
+ instance.get('/orders', {
186
+ schema: {
187
+ querystring: pageQuery({ maxLimit: 50 }),
188
+ response: { 200: pageResponse(orderSchema) },
189
+ },
190
+ }, async (req) => orders.list(req.query));
191
+ ```
192
+
193
+ `pageQuery` applies the defaults (`limit` 20, `offset` 0), so the handler always reads two integers.
194
+ `pageResponse(items, { total: true })` adds `total` to the schema — pass it wherever `paginate` gets
195
+ a count.
196
+
148
197
  ## `./context`
149
198
 
150
199
  An isolated `AsyncLocalStorage` store, owned by the service, so two services in one process never
@@ -425,6 +474,41 @@ Every Redis replica upserts the same scheduler and starts an equivalent Worker.
425
474
  leader; BullMQ coordinates which Worker receives each occurrence. A new occurrence is produced when
426
475
  the previous one starts, so global concurrency serializes slow runs rather than overlapping them.
427
476
 
477
+ ## `./env`
478
+
479
+ `createEnvReader()` reads `process.env` and **collects** what is wrong instead of failing on the
480
+ first problem, so a misconfigured deploy reports every missing or malformed variable in one boot
481
+ rather than one per restart. It does not decide how to fail: `issues()` hands the list back and the
482
+ service merges it with its own cross-field rules.
483
+
484
+ ```js
485
+ import { createEnvReader } from '@devindex/api-kit/env';
486
+
487
+ const env = createEnvReader();
488
+
489
+ export const config = Object.freeze({
490
+ port: env.int('PORT', { fallback: 3000 }),
491
+ mongoUri: env.str('MONGO_URI', { required: true }),
492
+ driver: env.oneOf('MESSAGING_DRIVER', ['memory', 'bullmq'], { fallback: 'memory' }),
493
+ });
494
+
495
+ export function assertConfig() {
496
+ const issues = env.issues();
497
+ if (issues.length === 0) return;
498
+
499
+ for (const issue of issues) console.error(`config: ${issue}`);
500
+ process.exit(1);
501
+ }
502
+ ```
503
+
504
+ `str`, `int` and `oneOf` take `fallback` (default `null`) and `required` (default `false`), and read
505
+ an empty string as an absent value — a variable left blank in a `.env` is not a value. A rejected
506
+ variable still returns its fallback, so the config object finishes building and `issues()` reports
507
+ everything in one pass.
508
+
509
+ A reader owns its own list, so config split across several modules is just the reader passed to each
510
+ one, and a test builds its own with `createEnvReader({ PORT: '3000' })` without touching the process.
511
+
428
512
  ## `./runtime`
429
513
 
430
514
  `onShutdown` wires `SIGINT`/`SIGTERM` to a teardown callback and exits — the one
@@ -449,6 +533,21 @@ A second signal arriving mid-drain is a no-op. A `close` that throws exits `1` a
449
533
  logging; one that hangs past `timeoutMs` (default `10_000`) force-exits `1` so a stuck
450
534
  drain cannot wedge the process. `signals` defaults to `['SIGINT', 'SIGTERM']`.
451
535
 
536
+ `onFatalError` covers the other exit: an uncaught exception or an unhandled rejection is
537
+ logged as fatal and the process exits `1`.
538
+
539
+ ```js
540
+ import { onFatalError } from '@devindex/api-kit/runtime';
541
+
542
+ onFatalError({ logger });
543
+ ```
544
+
545
+ It deliberately does not run the shutdown callback. After an uncaught throw the process
546
+ state is undefined, and a teardown running over it can hang or corrupt what it touches —
547
+ exiting fast leaves the restart to the supervisor. The logger is flushed first, so a
548
+ `pretty` transport writing from a worker thread does not lose the line that explains the
549
+ crash.
550
+
452
551
  ## Tests
453
552
 
454
553
  The default suite exercises every memory path and skips integration tests when Redis is absent:
@@ -0,0 +1,43 @@
1
+ /**
2
+ * In-process store for tests and local runs. Entries expire on read and are lost
3
+ * with the process.
4
+ *
5
+ * @return {object} A store: start, get, has, set, delete and stop.
6
+ */
7
+ export function memoryStore() {
8
+ const entries = new Map();
9
+
10
+ function read(key) {
11
+ const entry = entries.get(key);
12
+ if (!entry) return undefined;
13
+ if (entry.expiresAt !== 0 && entry.expiresAt <= Date.now()) {
14
+ entries.delete(key);
15
+ return undefined;
16
+ }
17
+ return entry.value;
18
+ }
19
+
20
+ return {
21
+ async start() {},
22
+
23
+ async get(key) {
24
+ return read(key);
25
+ },
26
+
27
+ async has(key) {
28
+ return read(key) !== undefined;
29
+ },
30
+
31
+ async set(key, value, ttl) {
32
+ entries.set(key, { value, expiresAt: ttl === 0 ? 0 : Date.now() + ttl });
33
+ },
34
+
35
+ async delete(key) {
36
+ return entries.delete(key);
37
+ },
38
+
39
+ async stop() {
40
+ entries.clear();
41
+ },
42
+ };
43
+ }
@@ -0,0 +1,53 @@
1
+ import { noopLogger } from '../../internal/logger.js';
2
+ import { loadIoredis, redisConnection } from '../../internal/redis.js';
3
+
4
+ /**
5
+ * Shared store on Redis, with expiration owned by Redis itself.
6
+ *
7
+ * @param {object} options
8
+ * @param {string} options.redisUrl
9
+ * @param {object} [options.logger]
10
+ * @return {object} A store: start, get, has, set, delete and stop.
11
+ */
12
+ export function redisStore({ redisUrl, logger = noopLogger } = {}) {
13
+ const connection = redisConnection(redisUrl);
14
+ let client;
15
+
16
+ return {
17
+ async start() {
18
+ const { default: Redis } = await loadIoredis();
19
+ client = new Redis(connection);
20
+ // ioredis emits 'error' on the client, and an EventEmitter with no 'error'
21
+ // listener throws: a dropped connection would kill the process the cache
22
+ // is only supposed to make faster.
23
+ client.on('error', (error) => logger.warn({ err: error }, 'cache connection error'));
24
+ },
25
+
26
+ async get(key) {
27
+ const value = await client.get(key);
28
+ return value === null ? undefined : value;
29
+ },
30
+
31
+ async has(key) {
32
+ return await client.exists(key) > 0;
33
+ },
34
+
35
+ async set(key, value, ttl) {
36
+ if (ttl === 0) await client.set(key, value);
37
+ else await client.set(key, value, 'PX', ttl);
38
+ },
39
+
40
+ async delete(key) {
41
+ return await client.del(key) > 0;
42
+ },
43
+
44
+ async stop() {
45
+ if (!client) return;
46
+ const closing = client;
47
+ client = undefined;
48
+ // quit() rejects when the connection is already down; the socket still has
49
+ // to be released, or shutdown hangs on a Redis that died first.
50
+ await closing.quit().catch(() => closing.disconnect());
51
+ },
52
+ };
53
+ }
package/cache/index.js ADDED
@@ -0,0 +1,155 @@
1
+ import { noopLogger } from '../internal/logger.js';
2
+ import { assertDuration, assertHandler, assertKey } from '../internal/validation.js';
3
+ import { memoryStore } from './drivers/memory.js';
4
+ import { redisStore } from './drivers/redis.js';
5
+
6
+ const STORES = { memory: memoryStore, redis: redisStore };
7
+
8
+ /**
9
+ * Creates a process-local or Redis-backed cache. Reads and writes degrade to a
10
+ * miss when the store fails; only `delete()` propagates, because a failed
11
+ * invalidation keeps serving stale data.
12
+ *
13
+ * @param {object} [options]
14
+ * @param {'memory'|'redis'} [options.driver='memory']
15
+ * @param {string} [options.redisUrl] - Required by the redis driver.
16
+ * @param {string} [options.prefix='app'] - Namespace every key is stored under.
17
+ * @param {number} [options.ttl=0] - Default lifetime in milliseconds; 0 never expires.
18
+ * @param {object} [options.logger]
19
+ * @return {object} The cache, not yet started; call start() first.
20
+ */
21
+ export function createCache({
22
+ driver = 'memory',
23
+ redisUrl,
24
+ prefix = 'app',
25
+ ttl = 0,
26
+ logger = noopLogger,
27
+ } = {}) {
28
+ const createStore = STORES[driver];
29
+ if (!createStore) {
30
+ throw new Error(`unknown cache driver "${driver}", expected ${Object.keys(STORES).join(' or ')}`);
31
+ }
32
+ const defaultTtl = assertDuration('ttl', ttl);
33
+ const store = createStore({ redisUrl, logger });
34
+ let state = 'idle';
35
+
36
+ function scope(key, action) {
37
+ if (state !== 'started') throw new Error(`the cache must be started before ${action}()`);
38
+ return `${prefix}:${assertKey(key)}`;
39
+ }
40
+
41
+ /**
42
+ * Reads a cached value.
43
+ *
44
+ * @param {string} key
45
+ * @return {Promise<*>} The stored value, or `undefined` on a miss.
46
+ */
47
+ async function get(key) {
48
+ const scoped = scope(key, 'get');
49
+ try {
50
+ const stored = await store.get(scoped);
51
+ return stored === undefined ? undefined : JSON.parse(stored);
52
+ } catch (error) {
53
+ logger.warn({ err: error, key: scoped }, 'cache read failed');
54
+ return undefined;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Whether a live entry exists, without transferring or parsing its value.
60
+ *
61
+ * @param {string} key
62
+ * @return {Promise<boolean>}
63
+ */
64
+ async function has(key) {
65
+ const scoped = scope(key, 'has');
66
+ try {
67
+ return await store.has(scoped);
68
+ } catch (error) {
69
+ logger.warn({ err: error, key: scoped }, 'cache read failed');
70
+ return false;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Stores a value, replacing any entry under the same key.
76
+ *
77
+ * @param {string} key
78
+ * @param {*} value - Must be JSON-serializable; `undefined` is how a miss is reported.
79
+ * @param {object} [options]
80
+ * @param {number} [options.ttl] - Overrides the cache default, in milliseconds.
81
+ * @return {Promise<void>}
82
+ */
83
+ async function set(key, value, { ttl: entryTtl = defaultTtl } = {}) {
84
+ const scoped = scope(key, 'set');
85
+ assertDuration('ttl', entryTtl);
86
+ // Both drivers store the serialized form, so a caller cannot mutate what the
87
+ // memory driver handed back and see the redis driver behave differently.
88
+ const serialized = JSON.stringify(value);
89
+ if (serialized === undefined) {
90
+ throw new TypeError('a cache value must be JSON-serializable and not `undefined`');
91
+ }
92
+ try {
93
+ await store.set(scoped, serialized, entryTtl);
94
+ } catch (error) {
95
+ logger.warn({ err: error, key: scoped }, 'cache write failed');
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Drops a cached value. Failures propagate: the caller decides what an
101
+ * invalidation that did not happen means for its request.
102
+ *
103
+ * @param {string} key
104
+ * @return {Promise<boolean>} True when an entry was removed.
105
+ */
106
+ async function remove(key) {
107
+ return store.delete(scope(key, 'delete'));
108
+ }
109
+
110
+ /**
111
+ * Reads a cached value, or produces it with `loader` and stores it.
112
+ *
113
+ * @param {string} key
114
+ * @param {() => (*|Promise<*>)} loader - Runs only on a miss; `undefined` is not cached.
115
+ * @param {object} [options]
116
+ * @param {number} [options.ttl] - Overrides the cache default, in milliseconds.
117
+ * @return {Promise<*>} The cached or freshly loaded value.
118
+ */
119
+ async function wrap(key, loader, { ttl: entryTtl = defaultTtl } = {}) {
120
+ scope(key, 'wrap');
121
+ assertHandler(key, loader);
122
+ const cached = await get(key);
123
+ if (cached !== undefined) return cached;
124
+ const value = await loader();
125
+ if (value !== undefined) await set(key, value, { ttl: entryTtl });
126
+ return value;
127
+ }
128
+
129
+ async function start() {
130
+ if (state === 'started') return;
131
+ if (state === 'stopped') throw new Error('a stopped cache cannot be restarted');
132
+ await store.start();
133
+ state = 'started';
134
+ }
135
+
136
+ async function stop() {
137
+ if (state === 'stopped') return;
138
+ await store.stop();
139
+ state = 'stopped';
140
+ }
141
+
142
+ return Object.freeze({
143
+ driver,
144
+ get,
145
+ has,
146
+ set,
147
+ delete: remove,
148
+ wrap,
149
+ start,
150
+ stop,
151
+ get state() {
152
+ return state;
153
+ },
154
+ });
155
+ }
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
+ }
@@ -1,8 +1,5 @@
1
- import {
2
- loadBullmq,
3
- queueName,
4
- redisConnection,
5
- } from '../../internal/bullmq.js';
1
+ import { loadBullmq, queueName } from '../../internal/bullmq.js';
2
+ import { redisConnection } from '../../internal/redis.js';
6
3
  import { noopLogger } from '../../internal/logger.js';
7
4
  import { subscriberStream } from '../internal.js';
8
5
 
package/http/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export { createApp } from './createApp.js';
2
2
  export { default as decorators } from './plugins/decorators.js';
3
3
  export { default as errorHandler, classifyError, STATUS_BY_CODE } from './plugins/errorHandler.js';
4
+ export { paginate } from './page.js';
4
5
  export { default as rawBody } from './plugins/rawBody.js';
5
6
  export { default as requestContext } from './plugins/requestContext.js';
6
7
  export {
package/http/page.js ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Shapes a page from rows fetched with `limit + 1` — the extra row is what reveals `hasMore`.
3
+ *
4
+ * @param {Array} rows - Up to `limit + 1` rows, in order.
5
+ * @param {number} limit - The page size the caller asked for.
6
+ * @param {number} [total] - Matching document count, when the resource exposes one.
7
+ * @return {{items: Array, hasMore: boolean, total?: number}} The page.
8
+ */
9
+ export function paginate(rows, limit, total) {
10
+ const hasMore = rows.length > limit;
11
+ const items = hasMore ? rows.slice(0, limit) : rows;
12
+ return total === undefined ? { items, hasMore } : { items, hasMore, total };
13
+ }
package/http/schema.js CHANGED
@@ -9,6 +9,14 @@ export function pageQuery({ maxLimit = 100, defaultLimit = 20 } = {}) {
9
9
  });
10
10
  }
11
11
 
12
+ export function pageResponse(items, { total = false } = {}) {
13
+ return objectSchema({
14
+ items: { type: 'array', items },
15
+ hasMore: { type: 'boolean' },
16
+ ...(total && { total: { type: 'integer', minimum: 0 } }),
17
+ }, total ? ['items', 'hasMore', 'total'] : ['items', 'hasMore']);
18
+ }
19
+
12
20
  export const stringSchema = (options = {}) => ({ type: 'string', minLength: 1, ...options });
13
21
  export const email = Object.freeze({ type: 'string', format: 'email' });
14
22
  export const dateTime = Object.freeze({ type: 'string', format: 'date-time' });
@@ -14,32 +14,6 @@ export async function loadBullmq() {
14
14
  }
15
15
  }
16
16
 
17
- export function redisConnection(redisUrl, { worker = false } = {}) {
18
- if (!redisUrl) throw new Error('a Redis connection requires `redisUrl`');
19
- let url;
20
- try {
21
- url = new URL(redisUrl);
22
- } catch (error) {
23
- throw new Error(`invalid redisUrl: ${redisUrl}`, { cause: error });
24
- }
25
- if (!['redis:', 'rediss:'].includes(url.protocol)) {
26
- throw new Error(`redisUrl must use redis:// or rediss://, got ${url.protocol}`);
27
- }
28
- const db = url.pathname.length > 1 ? Number(url.pathname.slice(1)) : 0;
29
- if (!Number.isInteger(db) || db < 0) throw new Error(`invalid Redis database in ${redisUrl}`);
30
-
31
- return {
32
- host: url.hostname,
33
- port: Number(url.port || 6379),
34
- db,
35
- ...(url.username ? { username: decodeURIComponent(url.username) } : {}),
36
- ...(url.password ? { password: decodeURIComponent(url.password) } : {}),
37
- ...(url.protocol === 'rediss:' ? { tls: {} } : {}),
38
- // BullMQ workers refuse to start unless this is null; producers fail fast.
39
- maxRetriesPerRequest: worker ? null : 1,
40
- };
41
- }
42
-
43
17
  function safeSegment(value) {
44
18
  const source = String(value);
45
19
  // Slugifying is lossy; the hash suffix keeps the queue name unique.
@@ -0,0 +1,36 @@
1
+ export async function loadIoredis() {
2
+ try {
3
+ return await import('ioredis');
4
+ } catch (error) {
5
+ if (['ERR_MODULE_NOT_FOUND', 'MODULE_NOT_FOUND'].includes(error?.code)) {
6
+ throw new Error('Redis support requires `ioredis`: install the package', { cause: error });
7
+ }
8
+ throw error;
9
+ }
10
+ }
11
+
12
+ export function redisConnection(redisUrl, { worker = false } = {}) {
13
+ if (!redisUrl) throw new Error('a Redis connection requires `redisUrl`');
14
+ let url;
15
+ try {
16
+ url = new URL(redisUrl);
17
+ } catch (error) {
18
+ throw new Error(`invalid redisUrl: ${redisUrl}`, { cause: error });
19
+ }
20
+ if (!['redis:', 'rediss:'].includes(url.protocol)) {
21
+ throw new Error(`redisUrl must use redis:// or rediss://, got ${url.protocol}`);
22
+ }
23
+ const db = url.pathname.length > 1 ? Number(url.pathname.slice(1)) : 0;
24
+ if (!Number.isInteger(db) || db < 0) throw new Error(`invalid Redis database in ${redisUrl}`);
25
+
26
+ return {
27
+ host: url.hostname,
28
+ port: Number(url.port || 6379),
29
+ db,
30
+ ...(url.username ? { username: decodeURIComponent(url.username) } : {}),
31
+ ...(url.password ? { password: decodeURIComponent(url.password) } : {}),
32
+ ...(url.protocol === 'rediss:' ? { tls: {} } : {}),
33
+ // BullMQ workers refuse to start unless this is null; producers fail fast.
34
+ maxRetriesPerRequest: worker ? null : 1,
35
+ };
36
+ }
@@ -1,8 +1,5 @@
1
- import {
2
- loadBullmq,
3
- queueName,
4
- redisConnection,
5
- } from '../../internal/bullmq.js';
1
+ import { loadBullmq, queueName } from '../../internal/bullmq.js';
2
+ import { redisConnection } from '../../internal/redis.js';
6
3
  import { noopLogger } from '../../internal/logger.js';
7
4
 
8
5
  /**
package/package.json CHANGED
@@ -1,10 +1,12 @@
1
1
  {
2
2
  "name": "@devindex/api-kit",
3
- "version": "0.1.0",
3
+ "version": "0.3.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
+ "./cache": "./cache/index.js",
7
8
  "./context": "./context/index.js",
9
+ "./env": "./env/index.js",
8
10
  "./errors": "./errors/index.js",
9
11
  "./events": "./events/index.js",
10
12
  "./http": "./http/index.js",
@@ -15,7 +17,9 @@
15
17
  "./package.json": "./package.json"
16
18
  },
17
19
  "files": [
20
+ "cache",
18
21
  "context",
22
+ "env",
19
23
  "errors",
20
24
  "events",
21
25
  "http",
@@ -48,7 +52,9 @@
48
52
  "schedule",
49
53
  "events",
50
54
  "event-bus",
51
- "pubsub"
55
+ "pubsub",
56
+ "cache",
57
+ "redis"
52
58
  ],
53
59
  "peerDependencies": {
54
60
  "@fastify/cors": "^11.0.0",
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
+ }
@@ -1,8 +1,5 @@
1
- import {
2
- loadBullmq,
3
- queueName,
4
- redisConnection,
5
- } from '../../internal/bullmq.js';
1
+ import { loadBullmq, queueName } from '../../internal/bullmq.js';
2
+ import { redisConnection } from '../../internal/redis.js';
6
3
  import { noopLogger } from '../../internal/logger.js';
7
4
 
8
5
  const SCHEDULER_ID = 'schedule';