@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
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import {
|
|
2
|
+
loadBullmq,
|
|
3
|
+
queueName,
|
|
4
|
+
redisConnection,
|
|
5
|
+
} from '../../internal/bullmq.js';
|
|
6
|
+
import { noopLogger } from '../../internal/logger.js';
|
|
7
|
+
import { subscriberStream } from '../internal.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Distributed backend on BullMQ, one queue per subscriber stream. Redis is only
|
|
11
|
+
* its store.
|
|
12
|
+
*
|
|
13
|
+
* @param {object} options
|
|
14
|
+
* @param {string} options.redisUrl
|
|
15
|
+
* @param {string} [options.prefix='app']
|
|
16
|
+
* @param {object} [options.logger]
|
|
17
|
+
* @return {object} A backend: add, work and stop.
|
|
18
|
+
*/
|
|
19
|
+
export function bullmqBackend({ redisUrl, prefix = 'app', logger = noopLogger } = {}) {
|
|
20
|
+
const queueConnection = redisConnection(redisUrl);
|
|
21
|
+
const workerConnection = redisConnection(redisUrl, { worker: true });
|
|
22
|
+
const queuePromises = new Map();
|
|
23
|
+
const workers = new Set();
|
|
24
|
+
|
|
25
|
+
/** Get or create the per-stream BullMQ queue with its retention policy. */
|
|
26
|
+
function getQueue(stream) {
|
|
27
|
+
let promise = queuePromises.get(stream);
|
|
28
|
+
if (!promise) {
|
|
29
|
+
promise = (async () => {
|
|
30
|
+
const { Queue } = await loadBullmq();
|
|
31
|
+
return new Queue(queueName(prefix, 'event', stream), {
|
|
32
|
+
connection: queueConnection,
|
|
33
|
+
defaultJobOptions: {
|
|
34
|
+
removeOnComplete: { age: 24 * 60 * 60, count: 1_000 },
|
|
35
|
+
removeOnFail: { age: 7 * 24 * 60 * 60 },
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
})();
|
|
39
|
+
queuePromises.set(stream, promise);
|
|
40
|
+
}
|
|
41
|
+
return promise;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
async add(stream, event, payload, { key, dedupId, eventId, attempts, backoff, delay = 0 }) {
|
|
46
|
+
const queue = await getQueue(stream);
|
|
47
|
+
const job = await queue.add(event, { payload, key, eventId }, {
|
|
48
|
+
attempts,
|
|
49
|
+
backoff,
|
|
50
|
+
deduplication: { id: dedupId },
|
|
51
|
+
...(delay > 0 ? { delay } : {}),
|
|
52
|
+
});
|
|
53
|
+
return job.id;
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
async work(event, subscriber, definition) {
|
|
57
|
+
const { Worker } = await loadBullmq();
|
|
58
|
+
const stream = subscriberStream(event, subscriber);
|
|
59
|
+
const queue = await getQueue(stream);
|
|
60
|
+
const worker = new Worker(queue.name, async (job, _token, signal) => {
|
|
61
|
+
if (job.name !== event) {
|
|
62
|
+
throw new Error(`event "${job.name}" reached the "${subscriber}" worker for "${event}"`);
|
|
63
|
+
}
|
|
64
|
+
const attempt = job.attemptsMade + 1;
|
|
65
|
+
const totalAttempts = job.opts.attempts ?? 1;
|
|
66
|
+
const log = logger.child({ event, subscriber, key: job.data.key, deliveryId: job.id });
|
|
67
|
+
try {
|
|
68
|
+
return await definition.handler(job.data.payload, {
|
|
69
|
+
event,
|
|
70
|
+
subscriber,
|
|
71
|
+
eventId: job.data.eventId,
|
|
72
|
+
key: job.data.key,
|
|
73
|
+
attempt,
|
|
74
|
+
attemptsLeft: totalAttempts - attempt,
|
|
75
|
+
signal,
|
|
76
|
+
log,
|
|
77
|
+
});
|
|
78
|
+
} catch (error) {
|
|
79
|
+
// A retry that BullMQ will re-run is a warning; only the last attempt is
|
|
80
|
+
// an error, so expected transient retries don't trip error-rate alerts.
|
|
81
|
+
const final = attempt >= totalAttempts;
|
|
82
|
+
log[final ? 'error' : 'warn']({ err: error, attempt, final }, 'event delivery failed');
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
}, { connection: workerConnection, concurrency: definition.concurrency });
|
|
86
|
+
workers.add(worker);
|
|
87
|
+
worker.on('error', (error) => logger.error({ err: error, event, subscriber }, 'event worker error'));
|
|
88
|
+
worker.on('stalled', (jobId) => {
|
|
89
|
+
logger.error({ event, subscriber, deliveryId: jobId }, 'event delivery stalled and will be delivered again');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Surface a worker that cannot reach Redis at start(), not silently later.
|
|
93
|
+
await worker.waitUntilReady();
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
// BullMQ owns the drain deadline once worker.close() starts, so the bus's
|
|
97
|
+
// timeoutMs is accepted for a uniform signature but not used here.
|
|
98
|
+
async stop() {
|
|
99
|
+
// Workers first: they stop taking deliveries and let the running ones finish.
|
|
100
|
+
const workerResults = await Promise.allSettled([...workers].map((worker) => worker.close()));
|
|
101
|
+
const queueResults = await Promise.allSettled([...queuePromises.values()]);
|
|
102
|
+
const closeResults = await Promise.allSettled(
|
|
103
|
+
queueResults.flatMap((result) => (result.status === 'fulfilled' ? [result.value.close()] : [])),
|
|
104
|
+
);
|
|
105
|
+
for (const result of [...workerResults, ...queueResults, ...closeResults]) {
|
|
106
|
+
if (result.status === 'rejected') logger.error({ err: result.reason }, 'event bus close failed');
|
|
107
|
+
}
|
|
108
|
+
workers.clear();
|
|
109
|
+
queuePromises.clear();
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { noopLogger } from '../../internal/logger.js';
|
|
3
|
+
import { backoffDelay } from '../../internal/retry.js';
|
|
4
|
+
import { subscriberStream } from '../internal.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* In-process backend for tests and local runs. Honors delay, attempts, backoff,
|
|
8
|
+
* deduplication and concurrency, and keeps nothing once the process exits.
|
|
9
|
+
*
|
|
10
|
+
* @param {object} [options]
|
|
11
|
+
* @param {object} [options.logger]
|
|
12
|
+
* @return {object} A backend: add, work and stop.
|
|
13
|
+
*/
|
|
14
|
+
export function memoryBackend({ logger = noopLogger } = {}) {
|
|
15
|
+
const consumers = new Map();
|
|
16
|
+
const lanes = new Map();
|
|
17
|
+
const dedup = new Map();
|
|
18
|
+
const timers = new Set();
|
|
19
|
+
const inFlight = new Set();
|
|
20
|
+
const shutdown = new AbortController();
|
|
21
|
+
let closed = false;
|
|
22
|
+
|
|
23
|
+
/** Get or create the per-stream queue: its backlog, head pointer and running count. */
|
|
24
|
+
function lane(stream) {
|
|
25
|
+
let entry = lanes.get(stream);
|
|
26
|
+
if (!entry) {
|
|
27
|
+
entry = { waiting: [], head: 0, running: 0 };
|
|
28
|
+
lanes.set(stream, entry);
|
|
29
|
+
}
|
|
30
|
+
return entry;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Resolve after `ms`, or early if the bus is stopped, without leaking the timer. */
|
|
34
|
+
function sleep(ms) {
|
|
35
|
+
if (ms === 0) return Promise.resolve();
|
|
36
|
+
return new Promise((resolve) => {
|
|
37
|
+
const done = () => {
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
timers.delete(timer);
|
|
40
|
+
shutdown.signal.removeEventListener('abort', done);
|
|
41
|
+
resolve();
|
|
42
|
+
};
|
|
43
|
+
const timer = setTimeout(done, ms);
|
|
44
|
+
timers.add(timer);
|
|
45
|
+
shutdown.signal.addEventListener('abort', done, { once: true });
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Deliver one job to its subscriber, retrying with backoff until it succeeds or exhausts attempts. */
|
|
50
|
+
async function run(consumer, job) {
|
|
51
|
+
const { definition, event, subscriber } = consumer;
|
|
52
|
+
const log = logger.child({ event, subscriber, key: job.key, deliveryId: job.id });
|
|
53
|
+
try {
|
|
54
|
+
for (let attempt = 1; attempt <= definition.attempts; attempt += 1) {
|
|
55
|
+
try {
|
|
56
|
+
await definition.handler(job.payload, {
|
|
57
|
+
event,
|
|
58
|
+
subscriber,
|
|
59
|
+
eventId: job.eventId,
|
|
60
|
+
key: job.key,
|
|
61
|
+
attempt,
|
|
62
|
+
attemptsLeft: definition.attempts - attempt,
|
|
63
|
+
signal: shutdown.signal,
|
|
64
|
+
log,
|
|
65
|
+
});
|
|
66
|
+
return;
|
|
67
|
+
} catch (error) {
|
|
68
|
+
// A retry that will run again is a warning; only the last failure is an
|
|
69
|
+
// error, so expected transient retries don't trip error-rate alerts.
|
|
70
|
+
const final = attempt === definition.attempts || shutdown.signal.aborted;
|
|
71
|
+
log[final ? 'error' : 'warn']({ err: error, attempt, final }, 'event delivery failed');
|
|
72
|
+
if (final) return;
|
|
73
|
+
await sleep(backoffDelay(definition.backoff, attempt));
|
|
74
|
+
if (shutdown.signal.aborted) return;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
} finally {
|
|
78
|
+
if (dedup.get(job.dedupId) === job.id) dedup.delete(job.dedupId);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Start as many queued deliveries for `stream` as concurrency allows, re-pumping as each finishes. */
|
|
83
|
+
function pump(stream) {
|
|
84
|
+
const consumer = consumers.get(stream);
|
|
85
|
+
if (closed || !consumer) return;
|
|
86
|
+
const entry = lane(stream);
|
|
87
|
+
while (entry.running < consumer.definition.concurrency && entry.head < entry.waiting.length) {
|
|
88
|
+
const job = entry.waiting[entry.head];
|
|
89
|
+
entry.waiting[entry.head] = undefined; // drop the payload reference for GC
|
|
90
|
+
entry.head += 1;
|
|
91
|
+
// `shift()` reindexes the whole backlog (O(n^2) under a burst); a head
|
|
92
|
+
// pointer keeps dequeue O(1), compacted so it can't grow unbounded.
|
|
93
|
+
if (entry.head > 1024 && entry.head * 2 >= entry.waiting.length) {
|
|
94
|
+
entry.waiting = entry.waiting.slice(entry.head);
|
|
95
|
+
entry.head = 0;
|
|
96
|
+
}
|
|
97
|
+
entry.running += 1;
|
|
98
|
+
const promise = run(consumer, job).finally(() => {
|
|
99
|
+
entry.running -= 1;
|
|
100
|
+
inFlight.delete(promise);
|
|
101
|
+
pump(stream);
|
|
102
|
+
});
|
|
103
|
+
inFlight.add(promise);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
async add(stream, event, payload, { key, dedupId, eventId, delay = 0 }) {
|
|
109
|
+
if (closed) throw new Error('cannot publish on a stopped event bus');
|
|
110
|
+
// Dedup is held only while the delivery is in flight, so the same key is
|
|
111
|
+
// free again once the delivery settles.
|
|
112
|
+
const held = dedup.get(dedupId);
|
|
113
|
+
if (held) return held;
|
|
114
|
+
|
|
115
|
+
const deliveryId = randomUUID();
|
|
116
|
+
dedup.set(dedupId, deliveryId);
|
|
117
|
+
const job = { id: deliveryId, dedupId, key, payload, eventId };
|
|
118
|
+
|
|
119
|
+
if (delay > 0) {
|
|
120
|
+
const timer = setTimeout(() => {
|
|
121
|
+
timers.delete(timer);
|
|
122
|
+
if (closed) return;
|
|
123
|
+
lane(stream).waiting.push(job);
|
|
124
|
+
pump(stream);
|
|
125
|
+
}, delay);
|
|
126
|
+
timers.add(timer);
|
|
127
|
+
} else {
|
|
128
|
+
lane(stream).waiting.push(job);
|
|
129
|
+
pump(stream);
|
|
130
|
+
}
|
|
131
|
+
return deliveryId;
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
async work(event, subscriber, definition) {
|
|
135
|
+
// Draining here picks up anything published before the worker existed.
|
|
136
|
+
const stream = subscriberStream(event, subscriber);
|
|
137
|
+
consumers.set(stream, { definition, event, subscriber });
|
|
138
|
+
pump(stream);
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
async stop({ timeoutMs = 30_000 } = {}) {
|
|
142
|
+
if (closed) return;
|
|
143
|
+
closed = true;
|
|
144
|
+
shutdown.abort();
|
|
145
|
+
for (const timer of timers) clearTimeout(timer);
|
|
146
|
+
timers.clear();
|
|
147
|
+
for (const entry of lanes.values()) {
|
|
148
|
+
entry.waiting.length = 0;
|
|
149
|
+
entry.head = 0;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// A handler that ignores `signal` would otherwise keep the process alive
|
|
153
|
+
// forever, so drain in-flight work under a deadline instead of unbounded.
|
|
154
|
+
const drained = await Promise.race([
|
|
155
|
+
Promise.allSettled([...inFlight]).then(() => true),
|
|
156
|
+
new Promise((resolve) => {
|
|
157
|
+
setTimeout(() => resolve(false), timeoutMs).unref?.();
|
|
158
|
+
}),
|
|
159
|
+
]);
|
|
160
|
+
if (!drained) {
|
|
161
|
+
logger.warn({ timeoutMs, pending: inFlight.size }, 'event bus stop timed out; abandoning in-flight deliveries');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
lanes.clear();
|
|
165
|
+
dedup.clear();
|
|
166
|
+
inFlight.clear();
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
package/events/index.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { noopLogger } from '../internal/logger.js';
|
|
3
|
+
import {
|
|
4
|
+
assertDuration,
|
|
5
|
+
assertHandler,
|
|
6
|
+
assertKey,
|
|
7
|
+
assertName,
|
|
8
|
+
} from '../internal/validation.js';
|
|
9
|
+
import {
|
|
10
|
+
DEFAULTS,
|
|
11
|
+
dedupId,
|
|
12
|
+
normalizeBackoff,
|
|
13
|
+
resolveDefinition,
|
|
14
|
+
} from '../internal/retry.js';
|
|
15
|
+
import { assertSubscriber, subscriberStream } from './internal.js';
|
|
16
|
+
import { memoryBackend } from './drivers/memory.js';
|
|
17
|
+
import { bullmqBackend } from './drivers/bullmq.js';
|
|
18
|
+
|
|
19
|
+
// Named for what delivers the events, not for its store: the distributed backend
|
|
20
|
+
// is BullMQ (Redis is only where it keeps state), so the semantics a caller gets —
|
|
21
|
+
// stalled recovery, deduplication, retention — are BullMQ's, and the name says so.
|
|
22
|
+
const BACKENDS = { memory: memoryBackend, bullmq: bullmqBackend };
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Creates a volatile local event bus or a distributed BullMQ event bus. Each
|
|
26
|
+
* published event fans out to every named subscriber independently.
|
|
27
|
+
*
|
|
28
|
+
* The lifecycle — declaration, fan-out, dedup keys, state — lives here; each
|
|
29
|
+
* backend only add()s, work()s and stop()s.
|
|
30
|
+
*
|
|
31
|
+
* @param {object} [options]
|
|
32
|
+
* @param {'memory'|'bullmq'} [options.driver='memory']
|
|
33
|
+
* @param {string} [options.redisUrl] - Required by the BullMQ driver.
|
|
34
|
+
* @param {string} [options.prefix='app']
|
|
35
|
+
* @param {object} [options.logger]
|
|
36
|
+
* @param {object} [options.defaults] - Per-subscriber attempts, backoff and concurrency.
|
|
37
|
+
* @return {object} The bus, not yet started; call start() first.
|
|
38
|
+
*/
|
|
39
|
+
export function createEventBus({
|
|
40
|
+
driver = 'memory',
|
|
41
|
+
redisUrl,
|
|
42
|
+
prefix = 'app',
|
|
43
|
+
logger = noopLogger,
|
|
44
|
+
defaults = {},
|
|
45
|
+
} = {}) {
|
|
46
|
+
const createBackend = BACKENDS[driver];
|
|
47
|
+
if (!createBackend) {
|
|
48
|
+
throw new Error(`unknown event bus driver "${driver}", expected ${Object.keys(BACKENDS).join(' or ')}`);
|
|
49
|
+
}
|
|
50
|
+
const merged = Object.freeze({
|
|
51
|
+
attempts: defaults.attempts ?? DEFAULTS.attempts,
|
|
52
|
+
backoff: normalizeBackoff(defaults.backoff ?? DEFAULTS.backoff),
|
|
53
|
+
concurrency: defaults.concurrency ?? DEFAULTS.concurrency,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const subscribers = new Map();
|
|
57
|
+
const backend = createBackend({ redisUrl, prefix, logger });
|
|
58
|
+
let state = 'idle';
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Declares a subscriber before start(); the (event, subscriber) pair must be unique.
|
|
62
|
+
*
|
|
63
|
+
* @param {string} event
|
|
64
|
+
* @param {string} subscriber - Names an independent, durable delivery stream for the event.
|
|
65
|
+
* @param {(payload: *, context: object) => (void|Promise<void>)} handler
|
|
66
|
+
* @param {object} [options] - Per-subscriber overrides: `attempts`, `backoff`, `concurrency`.
|
|
67
|
+
* @return {void}
|
|
68
|
+
*/
|
|
69
|
+
function subscribe(event, subscriber, handler, options = {}) {
|
|
70
|
+
if (state !== 'idle') throw new Error('subscribers must be declared before start()');
|
|
71
|
+
const eventName = assertName(event, 'event');
|
|
72
|
+
const subscriberName = assertSubscriber(subscriber);
|
|
73
|
+
assertHandler(subscriberStream(eventName, subscriberName), handler);
|
|
74
|
+
let group = subscribers.get(eventName);
|
|
75
|
+
if (!group) {
|
|
76
|
+
group = new Map();
|
|
77
|
+
subscribers.set(eventName, group);
|
|
78
|
+
}
|
|
79
|
+
if (group.has(subscriberName)) {
|
|
80
|
+
throw new Error(`subscriber "${subscriberName}" is already declared for event "${eventName}"`);
|
|
81
|
+
}
|
|
82
|
+
group.set(subscriberName, { handler, ...resolveDefinition(merged, options) });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Publishes an event to every declared subscriber.
|
|
87
|
+
*
|
|
88
|
+
* @param {string} event
|
|
89
|
+
* @param {*} payload - Passed to each subscriber handler as its first argument.
|
|
90
|
+
* @param {object} options
|
|
91
|
+
* @param {string} options.key - Required, non-empty idempotency key.
|
|
92
|
+
* @param {number} [options.delay=0] - Milliseconds before delivery becomes runnable.
|
|
93
|
+
* @return {Promise<{eventId: string, event: string, key: string, deliveries: ReadonlyArray<{subscriber: string, deliveryId: string}>}>}
|
|
94
|
+
*/
|
|
95
|
+
async function publish(event, payload, { key: givenKey, delay = 0 } = {}) {
|
|
96
|
+
if (state !== 'started') throw new Error('the event bus must be started before publish()');
|
|
97
|
+
const eventName = assertName(event, 'event');
|
|
98
|
+
const key = assertKey(givenKey);
|
|
99
|
+
assertDuration('delay', delay);
|
|
100
|
+
const eventId = randomUUID();
|
|
101
|
+
const group = subscribers.get(eventName);
|
|
102
|
+
if (!group || group.size === 0) {
|
|
103
|
+
// Publisher and subscriber are decoupled: a publish with no listeners is a
|
|
104
|
+
// valid no-op, not an error.
|
|
105
|
+
logger.debug({ event: eventName, eventId }, 'event published with no subscribers');
|
|
106
|
+
return Object.freeze({ eventId, event: eventName, key, deliveries: Object.freeze([]) });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Fan-out: each subscriber has its own stream, dedup identity and delivery.
|
|
110
|
+
const deliveries = await Promise.all([...group].map(async ([subscriber, definition]) => {
|
|
111
|
+
const stream = subscriberStream(eventName, subscriber);
|
|
112
|
+
const deliveryId = await backend.add(stream, eventName, payload, {
|
|
113
|
+
key,
|
|
114
|
+
dedupId: dedupId(stream, key),
|
|
115
|
+
eventId,
|
|
116
|
+
attempts: definition.attempts,
|
|
117
|
+
backoff: definition.backoff,
|
|
118
|
+
delay,
|
|
119
|
+
});
|
|
120
|
+
return Object.freeze({ subscriber, deliveryId });
|
|
121
|
+
}));
|
|
122
|
+
return Object.freeze({ eventId, event: eventName, key, deliveries: Object.freeze(deliveries) });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function start() {
|
|
126
|
+
if (state === 'started') return;
|
|
127
|
+
if (state === 'stopped') throw new Error('a stopped event bus cannot be restarted');
|
|
128
|
+
if (state !== 'idle') throw new Error('the event bus is already changing state');
|
|
129
|
+
state = 'starting';
|
|
130
|
+
const definitions = [...subscribers].flatMap(([event, group]) =>
|
|
131
|
+
[...group].map(([subscriber, definition]) => ({ event, subscriber, definition })));
|
|
132
|
+
// allSettled lets every worker register before a failure surfaces, so a
|
|
133
|
+
// rejection mid-start can't leak the workers its siblings already created.
|
|
134
|
+
const results = await Promise.allSettled(
|
|
135
|
+
definitions.map(({ event, subscriber, definition }) => backend.work(event, subscriber, definition)),
|
|
136
|
+
);
|
|
137
|
+
const failed = results.find((result) => result.status === 'rejected');
|
|
138
|
+
if (failed) {
|
|
139
|
+
state = 'stopping';
|
|
140
|
+
await backend.stop({});
|
|
141
|
+
state = 'stopped';
|
|
142
|
+
throw failed.reason;
|
|
143
|
+
}
|
|
144
|
+
state = 'started';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Stops intake and drains in-flight deliveries.
|
|
149
|
+
*
|
|
150
|
+
* @param {object} [options]
|
|
151
|
+
* @param {number} [options.timeoutMs=30000] - Drain deadline before abandoning
|
|
152
|
+
* in-flight deliveries (memory driver; the BullMQ driver lets BullMQ own the deadline).
|
|
153
|
+
* @return {Promise<void>}
|
|
154
|
+
*/
|
|
155
|
+
async function stop(options = {}) {
|
|
156
|
+
if (state === 'stopped') return;
|
|
157
|
+
state = 'stopping';
|
|
158
|
+
await backend.stop(options);
|
|
159
|
+
state = 'stopped';
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return Object.freeze({
|
|
163
|
+
driver,
|
|
164
|
+
subscribe,
|
|
165
|
+
publish,
|
|
166
|
+
start,
|
|
167
|
+
stop,
|
|
168
|
+
get state() {
|
|
169
|
+
return state;
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { assertName } from '../internal/validation.js';
|
|
2
|
+
|
|
3
|
+
export function assertSubscriber(name) {
|
|
4
|
+
return assertName(name, 'subscriber');
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Identity of one subscriber's durable stream for an event.
|
|
9
|
+
*
|
|
10
|
+
* @return {string} Key backing both the Redis queue name and the dedup id.
|
|
11
|
+
*/
|
|
12
|
+
export function subscriberStream(event, subscriber) {
|
|
13
|
+
return `${event}::${subscriber}`;
|
|
14
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import Fastify from 'fastify';
|
|
2
|
+
import decorators from './plugins/decorators.js';
|
|
3
|
+
import errorHandler from './plugins/errorHandler.js';
|
|
4
|
+
import rawBody from './plugins/rawBody.js';
|
|
5
|
+
import requestContext from './plugins/requestContext.js';
|
|
6
|
+
import requestId, { genReqId } from './plugins/requestId.js';
|
|
7
|
+
import { LOG_TYPE, withType } from '../log/index.js';
|
|
8
|
+
import corsPlugin from '@fastify/cors';
|
|
9
|
+
import helmetPlugin from '@fastify/helmet';
|
|
10
|
+
|
|
11
|
+
// CSP is a browser directive for rendered documents, inert on JSON responses,
|
|
12
|
+
// and its default breaks any HTML tooling bolted onto the API (Swagger, error
|
|
13
|
+
// pages). An app serving HTML re-enables it with its own directives.
|
|
14
|
+
const HELMET_DEFAULTS = Object.freeze({ global: true, contentSecurityPolicy: false });
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Builds the complete Fastify stack without binding a port, so tests can drive
|
|
18
|
+
* it with `app.inject()`. Async context is opt-in: pass a `context` store to
|
|
19
|
+
* propagate the correlation id below the HTTP layer; omitted, no ALS runs.
|
|
20
|
+
*
|
|
21
|
+
* @param {object} [options]
|
|
22
|
+
* @param {object} [options.logger] - A pino instance, base and untyped — the kit stamps `type:lifecycle` on the instance and `type:request` per request. Omitted disables Fastify's logging.
|
|
23
|
+
* @param {object} [options.context] - A context store from `./context`. Omitted disables ALS.
|
|
24
|
+
* @param {object|false} [options.helmet] - `@fastify/helmet` options. On by default with CSP off; pass `false` to disable. Re-enable CSP for HTML endpoints.
|
|
25
|
+
* @param {object|false} [options.cors] - `@fastify/cors` options. On by default reflecting the caller's origin; pass `false` to disable. Override `origin` for a credentialed API.
|
|
26
|
+
* @param {Function} [options.routes] - The app's route plugin, registered last.
|
|
27
|
+
* @param {Array<Function|[Function, object]>} [options.plugins] - Extra plugins, in order.
|
|
28
|
+
* @param {string[]} [options.requestProperties] - Names decorated as null request slots, filled per request.
|
|
29
|
+
* @param {boolean} [options.captureRawBody=false] - Keep the exact bytes on `req.rawBody`.
|
|
30
|
+
* @param {Array} [options.ajvPlugins] - ajv plugins, e.g. `[ajvFormats]` for `format` support.
|
|
31
|
+
* @param {object} [options.ajvOptions] - Merged over ajv's `customOptions`.
|
|
32
|
+
* @param {Function} [options.genReqId] - Correlation id strategy. Defaults to the kit's.
|
|
33
|
+
* @param {object} [options.fastify] - Merged last into the Fastify constructor options.
|
|
34
|
+
* @return {Promise<import('fastify').FastifyInstance>} The built instance, not listening.
|
|
35
|
+
*/
|
|
36
|
+
export async function createApp({
|
|
37
|
+
logger,
|
|
38
|
+
context,
|
|
39
|
+
helmet,
|
|
40
|
+
cors,
|
|
41
|
+
routes,
|
|
42
|
+
plugins = [],
|
|
43
|
+
requestProperties = [],
|
|
44
|
+
captureRawBody = false,
|
|
45
|
+
ajvPlugins = [],
|
|
46
|
+
ajvOptions = {},
|
|
47
|
+
genReqId: genReqIdOption = genReqId,
|
|
48
|
+
fastify: fastifyOptions = {},
|
|
49
|
+
} = {}) {
|
|
50
|
+
const app = Fastify({
|
|
51
|
+
...(logger
|
|
52
|
+
? {
|
|
53
|
+
loggerInstance: logger,
|
|
54
|
+
// Deriving from `parent` is what keeps Fastify's req/res serializers on
|
|
55
|
+
// the line; a child built off any other logger dumps the raw socket.
|
|
56
|
+
childLoggerFactory(parent, bindings, opts) {
|
|
57
|
+
return parent.child({ ...bindings, type: LOG_TYPE.REQUEST }, opts);
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
: { logger: false }),
|
|
61
|
+
genReqId: genReqIdOption,
|
|
62
|
+
ajv: {
|
|
63
|
+
customOptions: ajvOptions,
|
|
64
|
+
plugins: ajvPlugins,
|
|
65
|
+
},
|
|
66
|
+
...fastifyOptions,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// `app.log` is Fastify's wrapper around `logger`, the layer holding the req/res
|
|
70
|
+
// serializers. Re-binding it here, after the routes took their own logger from
|
|
71
|
+
// the options above, reaches only the instance's lines — server listening, plugin
|
|
72
|
+
// warnings; at construction it would leave `type` twice on every request line.
|
|
73
|
+
if (logger) app.log = withType(app.log, LOG_TYPE.LIFECYCLE);
|
|
74
|
+
|
|
75
|
+
await app.register(decorators, { properties: requestProperties });
|
|
76
|
+
await app.register(requestContext, { context });
|
|
77
|
+
await app.register(requestId);
|
|
78
|
+
if (captureRawBody) await app.register(rawBody);
|
|
79
|
+
await app.register(errorHandler);
|
|
80
|
+
|
|
81
|
+
if (helmet !== false) {
|
|
82
|
+
await app.register(helmetPlugin, helmet && helmet !== true ? { ...HELMET_DEFAULTS, ...helmet } : HELMET_DEFAULTS);
|
|
83
|
+
}
|
|
84
|
+
if (cors !== false) {
|
|
85
|
+
await app.register(corsPlugin, cors && cors !== true ? { origin: true, ...cors } : { origin: true });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
for (const entry of plugins) {
|
|
89
|
+
const [plugin, options] = Array.isArray(entry) ? entry : [entry];
|
|
90
|
+
await app.register(plugin, options);
|
|
91
|
+
}
|
|
92
|
+
if (routes) await app.register(routes);
|
|
93
|
+
|
|
94
|
+
return app;
|
|
95
|
+
}
|
package/http/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { createApp } from './createApp.js';
|
|
2
|
+
export { default as decorators } from './plugins/decorators.js';
|
|
3
|
+
export { default as errorHandler, classifyError, STATUS_BY_CODE } from './plugins/errorHandler.js';
|
|
4
|
+
export { default as rawBody } from './plugins/rawBody.js';
|
|
5
|
+
export { default as requestContext } from './plugins/requestContext.js';
|
|
6
|
+
export {
|
|
7
|
+
default as requestId,
|
|
8
|
+
genReqId,
|
|
9
|
+
isUuid,
|
|
10
|
+
REQUEST_ID_HEADER,
|
|
11
|
+
UUID_PATTERN,
|
|
12
|
+
} from './plugins/requestId.js';
|
|
13
|
+
export * from './schema.js';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import fp from 'fastify-plugin';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Declares request-scoped properties as empty (null) slots, filled per request.
|
|
5
|
+
*/
|
|
6
|
+
export default fp(async function decorators(app, { properties = [] } = {}) {
|
|
7
|
+
// A shared non-null default would bleed state across requests.
|
|
8
|
+
for (const name of properties) app.decorateRequest(name, null);
|
|
9
|
+
});
|