@devindex/api-kit 0.1.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 +21 -0
- package/README.md +468 -0
- package/context/index.js +43 -0
- package/errors/codes.js +21 -0
- package/errors/index.js +119 -0
- package/errors/stack.js +21 -0
- package/events/drivers/bullmq.js +112 -0
- package/events/drivers/memory.js +169 -0
- package/events/index.js +172 -0
- package/events/internal.js +14 -0
- package/http/createApp.js +95 -0
- package/http/index.js +13 -0
- package/http/plugins/decorators.js +9 -0
- package/http/plugins/errorHandler.js +124 -0
- package/http/plugins/rawBody.js +23 -0
- package/http/plugins/requestContext.js +9 -0
- package/http/plugins/requestId.js +21 -0
- package/http/schema.js +16 -0
- package/internal/bullmq.js +53 -0
- package/internal/logger.js +6 -0
- package/internal/retry.js +42 -0
- package/internal/validation.js +34 -0
- package/jobs/drivers/bullmq.js +122 -0
- package/jobs/drivers/memory.js +178 -0
- package/jobs/index.js +156 -0
- package/log/index.js +161 -0
- package/package.json +110 -0
- package/runtime/index.js +37 -0
- package/schedule/drivers/bullmq.js +127 -0
- package/schedule/drivers/memory.js +59 -0
- package/schedule/index.js +109 -0
- package/schedule/internal.js +21 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sergio Rodrigues
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
# @devindex/api-kit
|
|
2
|
+
|
|
3
|
+
Building blocks for backend services. Factories are inert until `start()` and never select a
|
|
4
|
+
driver from the environment.
|
|
5
|
+
|
|
6
|
+
## Installation
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm install @devindex/api-kit
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Node `>=22`. The memory drivers need no external service. BullMQ drivers load their optional peers
|
|
13
|
+
only on first use:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install bullmq ioredis
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## `./errors`
|
|
20
|
+
|
|
21
|
+
`DomainError` and its subtypes carry a code and details. The HTTP status is optional: the kit's own
|
|
22
|
+
codes are mapped by the HTTP layer, so an error thrown in a queue or CLI need not know about HTTP.
|
|
23
|
+
|
|
24
|
+
```js
|
|
25
|
+
throw new ConflictError('email already registered', {
|
|
26
|
+
details: [{ field: 'email' }],
|
|
27
|
+
});
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
An app defines its own errors by extending `DomainError` with a code of its own. `STATUS_BY_CODE`
|
|
31
|
+
cannot know that code, so declare the status where the error is defined — otherwise every app error
|
|
32
|
+
answers 400:
|
|
33
|
+
|
|
34
|
+
```js
|
|
35
|
+
class PaymentDeclinedError extends DomainError {
|
|
36
|
+
constructor(message = 'Payment declined', options = {}) {
|
|
37
|
+
super(message, { ...options, code: 'PAYMENT_DECLINED', status: 402 });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`status` is set only when given, so an error that never meets HTTP carries no trace of it. Subtypes
|
|
43
|
+
pin their code but not their status, so `new NotFoundError('order archived', { status: 410 })` works.
|
|
44
|
+
|
|
45
|
+
| Error | Code |
|
|
46
|
+
|---|---|
|
|
47
|
+
| `ValidationError` | `VALIDATION_ERROR` |
|
|
48
|
+
| `AuthError` | `UNAUTHORIZED` |
|
|
49
|
+
| `ForbiddenError` | `FORBIDDEN` |
|
|
50
|
+
| `NotFoundError` | `NOT_FOUND` |
|
|
51
|
+
| `MethodNotAllowedError` | `METHOD_NOT_ALLOWED` |
|
|
52
|
+
| `ConflictError` | `CONFLICT` |
|
|
53
|
+
| `LimitError` | `LIMIT_REACHED` |
|
|
54
|
+
| `PayloadError` | `PAYLOAD_TOO_LARGE` |
|
|
55
|
+
| `TooManyRequestsError` | `TOO_MANY_REQUESTS` |
|
|
56
|
+
| `UnavailableError` | `UNAVAILABLE` |
|
|
57
|
+
| `DomainError` | `DOMAIN_ERROR` |
|
|
58
|
+
|
|
59
|
+
Use `isDomainError(error)` instead of `instanceof`. The brand crosses multiple installed copies of
|
|
60
|
+
the package and stays out of serialized responses and logs.
|
|
61
|
+
|
|
62
|
+
## `./http`
|
|
63
|
+
|
|
64
|
+
`createApp()` builds the full Fastify stack and returns it **without listening**, so tests drive it
|
|
65
|
+
with `app.inject()` and starting the server stays the entrypoint's job. Every failure — a
|
|
66
|
+
`DomainError`, a schema rejection, an unexpected throw — leaves through one envelope:
|
|
67
|
+
|
|
68
|
+
```js
|
|
69
|
+
import { createApp } from '@devindex/api-kit/http';
|
|
70
|
+
|
|
71
|
+
const app = await createApp({
|
|
72
|
+
logger,
|
|
73
|
+
routes: async (instance) => {
|
|
74
|
+
instance.get('/orders/:id', async (req) => orders.find(req.params.id));
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
await app.listen({ port: 3000 });
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
A `ConflictError('email already registered')` becomes:
|
|
82
|
+
|
|
83
|
+
```json
|
|
84
|
+
{ "error": { "code": "CONFLICT", "message": "email already registered", "details": [], "requestId": "…" } }
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The status is the error's own `status` when it has one, otherwise `STATUS_BY_CODE[code]`, otherwise
|
|
88
|
+
400. Unclassified errors are logged and masked as a 500 that never leaks the original message. `createApp` options:
|
|
89
|
+
|
|
90
|
+
| Option | Default | Purpose |
|
|
91
|
+
|---|---|---|
|
|
92
|
+
| `logger` | none | A base pino instance; the kit types the lines itself (see below). Omitted disables Fastify logging |
|
|
93
|
+
| `context` | none | A `./context` store; omitted disables async context |
|
|
94
|
+
| `cors` | on (`origin: true`) | `@fastify/cors` options; pass `false` to disable |
|
|
95
|
+
| `helmet` | on (CSP off) | `@fastify/helmet` options; pass `false` to disable |
|
|
96
|
+
| `routes` | none | The app's route plugin, registered last |
|
|
97
|
+
| `plugins` | `[]` | Extra plugins `Function` or `[Function, options]`, in order |
|
|
98
|
+
| `requestProperties` | `{}` | Request decorators — the app's own vocabulary |
|
|
99
|
+
| `captureRawBody` | `false` | Keep the exact bytes on `req.rawBody` for webhook signatures |
|
|
100
|
+
| `ajvPlugins` / `ajvOptions` | `[]` / `{}` | ajv plugins and merged custom options |
|
|
101
|
+
| `genReqId` | kit default | Correlation id strategy |
|
|
102
|
+
| `fastify` | `{}` | Merged last into the Fastify constructor options |
|
|
103
|
+
|
|
104
|
+
Inbound `x-request-id` is reused only when it is a valid UUID, otherwise a fresh v4 is generated; the
|
|
105
|
+
id is always echoed back. `schema.js` ships ODM-agnostic JSON-Schema helpers — `objectSchema`,
|
|
106
|
+
`stringSchema`, `pageQuery`, `email`, `dateTime`, `dateKey`, `clock`.
|
|
107
|
+
|
|
108
|
+
### CORS and security headers
|
|
109
|
+
|
|
110
|
+
**CORS is on by default** with `origin: true`, reflecting the caller's origin, because a browser-facing
|
|
111
|
+
API almost always needs it. It registers before the routes.
|
|
112
|
+
|
|
113
|
+
```js
|
|
114
|
+
// Default: reflects any origin, no cookies.
|
|
115
|
+
await createApp({ routes });
|
|
116
|
+
|
|
117
|
+
// Server-to-server or same-origin app: turn it off.
|
|
118
|
+
await createApp({ cors: false, routes });
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
> **Footgun:** the default `origin: true` must **not** be combined with `credentials: true` — that
|
|
122
|
+
> lets any site read an authenticated response. A cookie/credentialed API must override `origin` with
|
|
123
|
+
> an explicit allowlist, which also makes non-listed origins get no CORS header at all:
|
|
124
|
+
>
|
|
125
|
+
> ```js
|
|
126
|
+
> await createApp({ cors: { origin: ['https://app.example.com'], credentials: true }, routes });
|
|
127
|
+
> ```
|
|
128
|
+
|
|
129
|
+
**`helmet` is on by default with `contentSecurityPolicy: false`.** CSP is a browser directive for
|
|
130
|
+
rendered documents — inert on JSON responses, and its default breaks any HTML tooling bolted onto the
|
|
131
|
+
API (Swagger, GraphQL playground, HTML error pages). The other headers (`X-Content-Type-Options:
|
|
132
|
+
nosniff`, frameguard, HSTS…) stay on.
|
|
133
|
+
|
|
134
|
+
```js
|
|
135
|
+
// Default: security headers on, CSP off.
|
|
136
|
+
await createApp({ routes });
|
|
137
|
+
|
|
138
|
+
// Turn it off entirely.
|
|
139
|
+
await createApp({ helmet: false, routes });
|
|
140
|
+
|
|
141
|
+
// An endpoint serving HTML re-enables CSP with its own policy.
|
|
142
|
+
await createApp({ helmet: { contentSecurityPolicy: { useDefaults: true } }, routes });
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Anything else — rate limits, compression — still goes through `plugins`, which registers after these
|
|
146
|
+
and before the routes.
|
|
147
|
+
|
|
148
|
+
## `./context`
|
|
149
|
+
|
|
150
|
+
An isolated `AsyncLocalStorage` store, owned by the service, so two services in one process never
|
|
151
|
+
leak each other's request metadata. It is **opt-in**: pass it to `createApp({ context })` and the
|
|
152
|
+
HTTP layer propagates `requestId`/`correlationId` below itself, readable in the service layer without
|
|
153
|
+
threading them through every call.
|
|
154
|
+
|
|
155
|
+
```js
|
|
156
|
+
import { createContextStore, serializableContext } from '@devindex/api-kit/context';
|
|
157
|
+
|
|
158
|
+
const context = createContextStore();
|
|
159
|
+
const app = await createApp({ context, routes });
|
|
160
|
+
|
|
161
|
+
// deeper in a use case:
|
|
162
|
+
const log = context.logger(logger); // child logger bound to the correlation id
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
`serializableContext(context.get())` keeps only the correlation fields, which is what should cross a
|
|
166
|
+
queue boundary into an event or job.
|
|
167
|
+
|
|
168
|
+
## `./log`
|
|
169
|
+
|
|
170
|
+
`createLogger()` builds a [pino](https://getpino.io) instance shaped for later analysis: JSON to
|
|
171
|
+
stdout, secret keys redacted, and every line ready to carry a `type` discriminator so a log store can
|
|
172
|
+
split request, event, job and integration lines apart. `pino` is an optional peer — install it (and
|
|
173
|
+
`pino-pretty` for local pretty-printing) only when you use this module:
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
npm install pino
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
```js
|
|
180
|
+
import { createLogger, LOG_TYPE, withType } from '@devindex/api-kit/log';
|
|
181
|
+
|
|
182
|
+
const logger = createLogger({
|
|
183
|
+
level: process.env.LOG_LEVEL ?? 'info',
|
|
184
|
+
base: { service: 'orders' },
|
|
185
|
+
context, // the ./context store — stamps requestId/correlationId on every line
|
|
186
|
+
pretty: process.env.NODE_ENV !== 'production',
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// Category a scope once; every line from it inherits the type and bindings.
|
|
190
|
+
withType(logger, LOG_TYPE.INTEGRATION, { provider: 'stripe' })
|
|
191
|
+
.info({ durationMs, status }, 'charge created');
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
`LOG_TYPE` is the closed vocabulary for the `type` field — `request`, `event`, `job`, `schedule`,
|
|
195
|
+
`integration`, `lifecycle`. Filtering then reads naturally: `type:integration AND level>=50` is every
|
|
196
|
+
integration error. Pass the same instance — the base one, never a `withType` child — to
|
|
197
|
+
`createApp({ logger })`: the HTTP layer is the one place a single logger emits two categories, so the
|
|
198
|
+
kit types them itself. What the Fastify instance says (`Server listening at…`, plugin warnings) is
|
|
199
|
+
`lifecycle`; what a request says (`incoming request`, `request completed`, the error envelope) is
|
|
200
|
+
`request`.
|
|
201
|
+
|
|
202
|
+
Secrets are redacted at logger creation, never at the call site: `DEFAULT_REDACT_PATHS` covers
|
|
203
|
+
`password`, `token`, `authorization`, `cookie` and friends across three nesting levels. Logging
|
|
204
|
+
`{ err }` runs pino's error serializer by default, yielding `type`/`message`/`stack`.
|
|
205
|
+
|
|
206
|
+
### Transports
|
|
207
|
+
|
|
208
|
+
`transport` is passed straight through to pino, so any target or fan-out works — a file, a service,
|
|
209
|
+
or several at once. It takes precedence over `pretty`:
|
|
210
|
+
|
|
211
|
+
```js
|
|
212
|
+
// One line to two sinks: pretty on the console, JSON to a file.
|
|
213
|
+
const logger = createLogger({
|
|
214
|
+
transport: {
|
|
215
|
+
targets: [
|
|
216
|
+
{ target: 'pino-pretty', options: { destination: 1 } },
|
|
217
|
+
{ target: 'pino/file', options: { destination: './logs/app.log' }, level: 'warn' },
|
|
218
|
+
],
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
In production, prefer the default JSON on stdout and let your collector (Datadog agent, Vector,
|
|
224
|
+
Fluent Bit…) ship it — skip `pretty` there. A `transport` cannot combine with a `destination` stream;
|
|
225
|
+
passing both throws.
|
|
226
|
+
|
|
227
|
+
## `./events`
|
|
228
|
+
|
|
229
|
+
Publish/subscribe with fan-out: one published event is delivered to every named
|
|
230
|
+
subscriber independently. Unlike a job, an event has no single consumer — a
|
|
231
|
+
publisher does not know or wait for who reacts.
|
|
232
|
+
|
|
233
|
+
```js
|
|
234
|
+
import { createEventBus } from '@devindex/api-kit/events';
|
|
235
|
+
|
|
236
|
+
const bus = createEventBus({
|
|
237
|
+
driver: 'bullmq',
|
|
238
|
+
redisUrl,
|
|
239
|
+
prefix: 'billing',
|
|
240
|
+
logger,
|
|
241
|
+
defaults: {
|
|
242
|
+
attempts: 3,
|
|
243
|
+
backoff: { type: 'exponential', delay: 1_000 },
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
bus.subscribe('user.registered', 'send-welcome', async ({ userId }, { key, log }) => {
|
|
248
|
+
await mailer.welcome(userId, { idempotencyKey: key });
|
|
249
|
+
log.info({ userId }, 'welcome sent');
|
|
250
|
+
}, { concurrency: 5 });
|
|
251
|
+
|
|
252
|
+
bus.subscribe('user.registered', 'provision-workspace', async ({ userId }) => {
|
|
253
|
+
await workspaces.provision(userId);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
await bus.start();
|
|
257
|
+
await bus.publish('user.registered', { userId }, {
|
|
258
|
+
key: `user:${userId}`,
|
|
259
|
+
delay: 0,
|
|
260
|
+
});
|
|
261
|
+
await bus.stop();
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Subscribers must be declared before `start()`. A subscriber has a **stable name**
|
|
265
|
+
that is unique per event; declaring several subscribers on the same event is how
|
|
266
|
+
fan-out happens. Bus defaults can be overridden per subscriber with `attempts`,
|
|
267
|
+
`backoff` and `concurrency`; a publish only carries the required logical `key` and
|
|
268
|
+
an optional `delay`.
|
|
269
|
+
|
|
270
|
+
The handler context is:
|
|
271
|
+
|
|
272
|
+
```js
|
|
273
|
+
{ event, subscriber, eventId, key, attempt, attemptsLeft, signal, log }
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
`signal` aborts when `stop()` begins draining, per subscriber delivery; a long
|
|
277
|
+
handler should observe it.
|
|
278
|
+
|
|
279
|
+
`publish()` returns `{ eventId, event, key, deliveries }`, where `deliveries` lists
|
|
280
|
+
one `{ subscriber, deliveryId }` per subscriber reached. Publishing an event with no
|
|
281
|
+
subscribers is a valid no-op that returns an empty `deliveries` list.
|
|
282
|
+
|
|
283
|
+
### Identity
|
|
284
|
+
|
|
285
|
+
Each subscriber is an independent stream: the same event and key deliver once per
|
|
286
|
+
subscriber while that delivery is waiting, delayed, active or retrying, and the
|
|
287
|
+
identity is released after success or final failure. Two subscribers of the same
|
|
288
|
+
event never share a queue and never collapse each other's deliveries, so one
|
|
289
|
+
subscriber failing and retrying never blocks another.
|
|
290
|
+
|
|
291
|
+
Handlers must remain idempotent. Each subscriber gets at-least-once delivery: a
|
|
292
|
+
process can finish the external effect and die before acknowledging the delivery.
|
|
293
|
+
|
|
294
|
+
### Driver guarantees
|
|
295
|
+
|
|
296
|
+
| | `memory` | `bullmq` |
|
|
297
|
+
|---|---|---|
|
|
298
|
+
| External service | None | Redis |
|
|
299
|
+
| Multiple replicas | One private bus per replica | One distributed queue per subscriber |
|
|
300
|
+
| Survives restart | No | Yes |
|
|
301
|
+
| Stalled redelivery | No | Yes |
|
|
302
|
+
| Deduplication scope | Process | Cluster |
|
|
303
|
+
| Fan-out isolation | Per process | One durable queue per subscriber |
|
|
304
|
+
|
|
305
|
+
The bullmq driver keeps one BullMQ queue and worker per `(event, subscriber)` pair,
|
|
306
|
+
so each subscriber is a durable consumer group. Every replica declares the same
|
|
307
|
+
subscribers; BullMQ routes each delivery to one worker within a subscriber, with
|
|
308
|
+
no elected leader. The memory driver is for development, tests and single-process
|
|
309
|
+
workloads.
|
|
310
|
+
|
|
311
|
+
## `./jobs`
|
|
312
|
+
|
|
313
|
+
Background work with one deliberately small contract:
|
|
314
|
+
|
|
315
|
+
```js
|
|
316
|
+
import { createJobQueue } from '@devindex/api-kit/jobs';
|
|
317
|
+
|
|
318
|
+
const jobs = createJobQueue({
|
|
319
|
+
driver: 'bullmq',
|
|
320
|
+
redisUrl,
|
|
321
|
+
prefix: 'billing',
|
|
322
|
+
logger,
|
|
323
|
+
defaults: {
|
|
324
|
+
attempts: 3,
|
|
325
|
+
backoff: { type: 'exponential', delay: 1_000 },
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
jobs.define('send-receipt', async ({ orderId }, { key, log }) => {
|
|
330
|
+
await mailer.send(orderId, { idempotencyKey: key });
|
|
331
|
+
log.info({ orderId }, 'receipt sent');
|
|
332
|
+
}, { concurrency: 5 });
|
|
333
|
+
|
|
334
|
+
await jobs.start();
|
|
335
|
+
await jobs.enqueue('send-receipt', { orderId }, {
|
|
336
|
+
key: `receipt:${orderId}`,
|
|
337
|
+
delay: 0,
|
|
338
|
+
});
|
|
339
|
+
await jobs.stop();
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
Jobs must be declared before `start()` and only declared names can be enqueued. Queue defaults can
|
|
343
|
+
be overridden by a definition with `attempts`, `backoff` and `concurrency`; an enqueue only carries
|
|
344
|
+
the required logical `key` and an optional `delay`.
|
|
345
|
+
|
|
346
|
+
The handler context is:
|
|
347
|
+
|
|
348
|
+
```js
|
|
349
|
+
{ name, jobId, key, attempt, attemptsLeft, signal, log }
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
`signal` aborts when `stop()` begins draining; a long handler should observe it.
|
|
353
|
+
|
|
354
|
+
`idle()` resolves when no job is delayed, queued or running. Both drivers expose the
|
|
355
|
+
same behavior, so tests and local tools do not need to know which backend is active.
|
|
356
|
+
|
|
357
|
+
### Identity
|
|
358
|
+
|
|
359
|
+
The same name and key produce one job while that job is waiting, delayed, active or retrying. The
|
|
360
|
+
identity is released after success or final failure. Permanent idempotency belongs in the database
|
|
361
|
+
that commits the external effect.
|
|
362
|
+
|
|
363
|
+
Handlers must remain idempotent. A durable queue provides at-least-once delivery: a process can
|
|
364
|
+
finish the external effect and die before acknowledging the job.
|
|
365
|
+
|
|
366
|
+
### Driver guarantees
|
|
367
|
+
|
|
368
|
+
| | `memory` | `bullmq` |
|
|
369
|
+
|---|---|---|
|
|
370
|
+
| External service | None | Redis |
|
|
371
|
+
| Multiple replicas | One private queue per replica | One distributed queue |
|
|
372
|
+
| Survives restart | No | Yes |
|
|
373
|
+
| Stalled redelivery | No | Yes |
|
|
374
|
+
| Deduplication scope | Process | Cluster |
|
|
375
|
+
|
|
376
|
+
The memory driver is for development, tests and basic single-process workloads. It can run in many
|
|
377
|
+
replicas, but each replica processes only the jobs enqueued into that process.
|
|
378
|
+
|
|
379
|
+
## `./schedule`
|
|
380
|
+
|
|
381
|
+
Cron is independent from jobs. A schedule can execute a use case directly or enqueue a job by
|
|
382
|
+
closing over a queue owned by the application.
|
|
383
|
+
|
|
384
|
+
```js
|
|
385
|
+
import { createSchedule } from '@devindex/api-kit/schedule';
|
|
386
|
+
|
|
387
|
+
const schedule = createSchedule({
|
|
388
|
+
driver: 'bullmq',
|
|
389
|
+
redisUrl,
|
|
390
|
+
prefix: 'billing',
|
|
391
|
+
logger,
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
schedule.define(
|
|
395
|
+
'daily-settlement',
|
|
396
|
+
{ pattern: '0 0 3 * * *', timeZone: 'America/Sao_Paulo' },
|
|
397
|
+
async ({ signal, log }) => settle({ signal, log }),
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
await schedule.start();
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
The pattern supports the six-field cron form, with seconds first; the standard
|
|
404
|
+
five-field form works too. The handler receives:
|
|
405
|
+
|
|
406
|
+
```js
|
|
407
|
+
{ name, signal, log }
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
`list()` returns local declarations. `remove(name)` stops a local clock or explicitly removes the
|
|
411
|
+
corresponding BullMQ Job Scheduler. `stop()` never removes Redis schedulers because one replica
|
|
412
|
+
cannot know whether another still serves them.
|
|
413
|
+
|
|
414
|
+
### Driver guarantees
|
|
415
|
+
|
|
416
|
+
| | `memory` | `bullmq` |
|
|
417
|
+
|---|---|---|
|
|
418
|
+
| Clock | Croner in each process | BullMQ Job Scheduler |
|
|
419
|
+
| Multiple replicas | One run per replica | One cluster run per occurrence |
|
|
420
|
+
| Survives restart | No | Yes |
|
|
421
|
+
| Overlap of the same schedule | Skipped per process | Globally serialized |
|
|
422
|
+
| Window with no replicas | Missed | One delayed run remains |
|
|
423
|
+
|
|
424
|
+
Every Redis replica upserts the same scheduler and starts an equivalent Worker. There is no elected
|
|
425
|
+
leader; BullMQ coordinates which Worker receives each occurrence. A new occurrence is produced when
|
|
426
|
+
the previous one starts, so global concurrency serializes slow runs rather than overlapping them.
|
|
427
|
+
|
|
428
|
+
## `./runtime`
|
|
429
|
+
|
|
430
|
+
`onShutdown` wires `SIGINT`/`SIGTERM` to a teardown callback and exits — the one
|
|
431
|
+
lifecycle step services forget. It owns only the signal, running once and the exit
|
|
432
|
+
code; the order of teardown and which components to stop stay yours, so any subset
|
|
433
|
+
is just the calls you put in the callback.
|
|
434
|
+
|
|
435
|
+
```js
|
|
436
|
+
import { onShutdown } from '@devindex/api-kit/runtime';
|
|
437
|
+
|
|
438
|
+
onShutdown(async () => {
|
|
439
|
+
await app.close(); // stop accepting HTTP first
|
|
440
|
+
await Promise.allSettled([ // then drain the consumers behind it
|
|
441
|
+
jobs.stop({ timeoutMs: 10_000 }),
|
|
442
|
+
bus.stop({ timeoutMs: 10_000 }),
|
|
443
|
+
schedule.stop(),
|
|
444
|
+
]);
|
|
445
|
+
}, { logger });
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
A second signal arriving mid-drain is a no-op. A `close` that throws exits `1` after
|
|
449
|
+
logging; one that hangs past `timeoutMs` (default `10_000`) force-exits `1` so a stuck
|
|
450
|
+
drain cannot wedge the process. `signals` defaults to `['SIGINT', 'SIGTERM']`.
|
|
451
|
+
|
|
452
|
+
## Tests
|
|
453
|
+
|
|
454
|
+
The default suite exercises every memory path and skips integration tests when Redis is absent:
|
|
455
|
+
|
|
456
|
+
```bash
|
|
457
|
+
npm test -w @devindex/api-kit
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
Redis is mandatory in the integration command and in CI:
|
|
461
|
+
|
|
462
|
+
```bash
|
|
463
|
+
REDIS_URL=redis://127.0.0.1:6379 npm run test:redis -w @devindex/api-kit
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
## License
|
|
467
|
+
|
|
468
|
+
MIT
|
package/context/index.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
|
|
3
|
+
const EMPTY_CONTEXT = Object.freeze({});
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Creates an isolated async context store.
|
|
7
|
+
*
|
|
8
|
+
* @return {{get: Function, run: Function}}
|
|
9
|
+
* Frozen accessor bound to its own AsyncLocalStorage.
|
|
10
|
+
*/
|
|
11
|
+
export function createContextStore() {
|
|
12
|
+
// A service owns its store, so two services in one process never leak
|
|
13
|
+
// request or background-work metadata across each other.
|
|
14
|
+
const storage = new AsyncLocalStorage();
|
|
15
|
+
|
|
16
|
+
function get() {
|
|
17
|
+
return storage.getStore() ?? EMPTY_CONTEXT;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function run(values, callback, ...args) {
|
|
21
|
+
const parent = get();
|
|
22
|
+
return storage.run(Object.freeze({ ...parent, ...values }), callback, ...args);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return Object.freeze({ get, run });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Extracts the context fields safe to carry across a queue boundary.
|
|
30
|
+
*
|
|
31
|
+
* @param {Record<string, unknown>} [context]
|
|
32
|
+
* @return {{requestId?: string, correlationId?: string}} Non-empty string fields only.
|
|
33
|
+
*/
|
|
34
|
+
export function serializableContext(context = {}) {
|
|
35
|
+
const result = {};
|
|
36
|
+
// Only correlation fields cross a queue boundary.
|
|
37
|
+
for (const key of ['requestId', 'correlationId']) {
|
|
38
|
+
if (typeof context[key] === 'string' && context[key].length > 0) {
|
|
39
|
+
result[key] = context[key];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
43
|
+
}
|
package/errors/codes.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Codes reach clients and log queries, so they outlive messages and statuses.
|
|
2
|
+
// No subtype throws INTERNAL_ERROR: the HTTP handler produces it for whatever
|
|
3
|
+
// it could not classify.
|
|
4
|
+
|
|
5
|
+
export const ERROR_CODE = Object.freeze({
|
|
6
|
+
VALIDATION_ERROR: 'VALIDATION_ERROR',
|
|
7
|
+
UNAUTHORIZED: 'UNAUTHORIZED',
|
|
8
|
+
FORBIDDEN: 'FORBIDDEN',
|
|
9
|
+
NOT_FOUND: 'NOT_FOUND',
|
|
10
|
+
METHOD_NOT_ALLOWED: 'METHOD_NOT_ALLOWED',
|
|
11
|
+
CONFLICT: 'CONFLICT',
|
|
12
|
+
LIMIT_REACHED: 'LIMIT_REACHED',
|
|
13
|
+
PAYLOAD_TOO_LARGE: 'PAYLOAD_TOO_LARGE',
|
|
14
|
+
TOO_MANY_REQUESTS: 'TOO_MANY_REQUESTS',
|
|
15
|
+
UNAVAILABLE: 'UNAVAILABLE',
|
|
16
|
+
DOMAIN_ERROR: 'DOMAIN_ERROR',
|
|
17
|
+
INTERNAL_ERROR: 'INTERNAL_ERROR',
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/** Every code, for a JSON Schema `enum` on the error envelope. */
|
|
21
|
+
export const ERROR_CODES = Object.freeze(Object.values(ERROR_CODE));
|
package/errors/index.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { ERROR_CODE } from './codes.js';
|
|
2
|
+
|
|
3
|
+
export { ERROR_CODE, ERROR_CODES } from './codes.js';
|
|
4
|
+
export { cleanStack } from './stack.js';
|
|
5
|
+
|
|
6
|
+
// Two copies of the package give the app two DomainError classes, and
|
|
7
|
+
// `instanceof` rejects the one it did not import. A registry symbol is shared.
|
|
8
|
+
const DOMAIN_ERROR_BRAND = Symbol.for('@devindex/api-kit/DomainError');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {object} DomainErrorOptions
|
|
12
|
+
* @property {string} [code=ERROR_CODE.DOMAIN_ERROR] - Ignored by the subtypes, which pin their own.
|
|
13
|
+
* @property {number} [status] - HTTP status. Omitted, the HTTP layer derives one from `code`.
|
|
14
|
+
* @property {Array<object>} [details=[]] - Machine-readable specifics, e.g. `[{ field: 'email' }]`.
|
|
15
|
+
* @property {unknown} [cause] - The underlying failure, kept for the logs.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Base of every error the domain throws on purpose. Carries a code, details and
|
|
20
|
+
* an optional HTTP status.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} message - Sent in the envelope, so safe to show a client.
|
|
23
|
+
* @param {DomainErrorOptions} [options]
|
|
24
|
+
*/
|
|
25
|
+
export class DomainError extends Error {
|
|
26
|
+
constructor(message, { code = ERROR_CODE.DOMAIN_ERROR, status, details = [], cause } = {}) {
|
|
27
|
+
super(message, { cause });
|
|
28
|
+
// new.target so each subclass reports its own name, not "DomainError".
|
|
29
|
+
this.name = new.target.name;
|
|
30
|
+
this.code = code;
|
|
31
|
+
// Absent rather than undefined: `status` reaches log lines and any spread of
|
|
32
|
+
// the error, so an error that never meets HTTP carries no trace of it.
|
|
33
|
+
if (status !== undefined) this.status = status;
|
|
34
|
+
this.details = details;
|
|
35
|
+
// Non-enumerable: the brand must not reach a log line or a response body.
|
|
36
|
+
Object.defineProperty(this, DOMAIN_ERROR_BRAND, { value: true });
|
|
37
|
+
Error.captureStackTrace?.(this, new.target);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Input the schema accepted but the domain rejects — a range ending before it starts. */
|
|
42
|
+
export class ValidationError extends DomainError {
|
|
43
|
+
constructor(message = 'Validation failed', options = {}) {
|
|
44
|
+
super(message, { ...options, code: ERROR_CODE.VALIDATION_ERROR });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Missing, malformed or expired credentials — the caller is unknown. */
|
|
49
|
+
export class AuthError extends DomainError {
|
|
50
|
+
constructor(message = 'Unauthorized', options = {}) {
|
|
51
|
+
super(message, { ...options, code: ERROR_CODE.UNAUTHORIZED });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Authenticated, but not allowed to do this. Re-authenticating will not help. */
|
|
56
|
+
export class ForbiddenError extends DomainError {
|
|
57
|
+
constructor(message = 'Forbidden', options = {}) {
|
|
58
|
+
super(message, { ...options, code: ERROR_CODE.FORBIDDEN });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The addressed resource does not exist, or is not visible to this caller. */
|
|
63
|
+
export class NotFoundError extends DomainError {
|
|
64
|
+
constructor(message = 'Not found', options = {}) {
|
|
65
|
+
super(message, { ...options, code: ERROR_CODE.NOT_FOUND });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The resource exists, but does not support the requested HTTP method. */
|
|
70
|
+
export class MethodNotAllowedError extends DomainError {
|
|
71
|
+
constructor(message = 'Method not allowed', options = {}) {
|
|
72
|
+
super(message, { ...options, code: ERROR_CODE.METHOD_NOT_ALLOWED });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The write would break a uniqueness or state invariant. */
|
|
77
|
+
export class ConflictError extends DomainError {
|
|
78
|
+
constructor(message = 'Conflict', options = {}) {
|
|
79
|
+
super(message, { ...options, code: ERROR_CODE.CONFLICT });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A quota or plan allowance is used up. A bigger plan unblocks it, waiting does not. */
|
|
84
|
+
export class LimitError extends DomainError {
|
|
85
|
+
constructor(message = 'Limit reached', options = {}) {
|
|
86
|
+
super(message, { ...options, code: ERROR_CODE.LIMIT_REACHED });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The request payload exceeds the size the server accepts. */
|
|
91
|
+
export class PayloadError extends DomainError {
|
|
92
|
+
constructor(message = 'Payload too large', options = {}) {
|
|
93
|
+
super(message, { ...options, code: ERROR_CODE.PAYLOAD_TOO_LARGE });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Rate limited. The same call is expected to succeed later, unchanged. */
|
|
98
|
+
export class TooManyRequestsError extends DomainError {
|
|
99
|
+
constructor(message = 'Too many requests', options = {}) {
|
|
100
|
+
super(message, { ...options, code: ERROR_CODE.TOO_MANY_REQUESTS });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A dependency is down or shedding load — an open breaker, a provider timing out. */
|
|
105
|
+
export class UnavailableError extends DomainError {
|
|
106
|
+
constructor(message = 'Service unavailable', options = {}) {
|
|
107
|
+
super(message, { ...options, code: ERROR_CODE.UNAVAILABLE });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Whether the domain threw this on purpose. Use it over `instanceof`.
|
|
113
|
+
*
|
|
114
|
+
* @param {unknown} error - Anything, including `null` and non-objects.
|
|
115
|
+
* @return {boolean} True for a DomainError from any copy of this package.
|
|
116
|
+
*/
|
|
117
|
+
export function isDomainError(error) {
|
|
118
|
+
return Boolean(error?.[DOMAIN_ERROR_BRAND]);
|
|
119
|
+
}
|
package/errors/stack.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { sep } from 'node:path';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Strips the machine-specific prefix from every frame of a stack trace, leaving
|
|
6
|
+
* each path relative to `cwd`. Node-internal (`node:…`) frames and anything
|
|
7
|
+
* outside `cwd` are left as-is.
|
|
8
|
+
*
|
|
9
|
+
* @param {string} stack - An `Error.stack` string. Non-strings pass through.
|
|
10
|
+
* @param {object} [options]
|
|
11
|
+
* @param {string} [options.cwd=process.cwd()]
|
|
12
|
+
* @return {string} The stack with absolute repo paths relativized.
|
|
13
|
+
*/
|
|
14
|
+
export function cleanStack(stack, { cwd = process.cwd() } = {}) {
|
|
15
|
+
if (typeof stack !== 'string') return stack;
|
|
16
|
+
const base = cwd.endsWith(sep) ? cwd : cwd + sep;
|
|
17
|
+
// ESM frames render file URLs (`file:///…`), CJS frames render plain paths.
|
|
18
|
+
// The URL form embeds the plain path, so it must be stripped first.
|
|
19
|
+
const fileUrlBase = pathToFileURL(base).href;
|
|
20
|
+
return stack.replaceAll(fileUrlBase, '').replaceAll(base, '');
|
|
21
|
+
}
|