@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 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
@@ -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
+ }
@@ -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));
@@ -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
+ }
@@ -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
+ }