@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/jobs/index.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { noopLogger } from '../internal/logger.js';
|
|
2
|
+
import {
|
|
3
|
+
assertDuration,
|
|
4
|
+
assertHandler,
|
|
5
|
+
assertKey,
|
|
6
|
+
assertName,
|
|
7
|
+
} from '../internal/validation.js';
|
|
8
|
+
import {
|
|
9
|
+
DEFAULTS,
|
|
10
|
+
dedupId,
|
|
11
|
+
normalizeBackoff,
|
|
12
|
+
resolveDefinition,
|
|
13
|
+
} from '../internal/retry.js';
|
|
14
|
+
import { memoryBackend } from './drivers/memory.js';
|
|
15
|
+
import { bullmqBackend } from './drivers/bullmq.js';
|
|
16
|
+
|
|
17
|
+
// Named for what runs the jobs, not for its store: the distributed backend is
|
|
18
|
+
// BullMQ (Redis is only where it keeps state), so the semantics a caller gets —
|
|
19
|
+
// stalled recovery, deduplication, retention — are BullMQ's, and the name says so.
|
|
20
|
+
const BACKENDS = { memory: memoryBackend, bullmq: bullmqBackend };
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Creates a volatile local queue or a distributed BullMQ queue.
|
|
24
|
+
*
|
|
25
|
+
* The lifecycle — declaration, dedup keys, state — lives here; each backend only
|
|
26
|
+
* add()s, work()s and stop()s. enqueue() needs neither define() nor start(), so a
|
|
27
|
+
* producer-only process dispatches by name without running a worker.
|
|
28
|
+
*
|
|
29
|
+
* @param {object} [options]
|
|
30
|
+
* @param {'memory'|'bullmq'} [options.driver='memory']
|
|
31
|
+
* @param {string} [options.redisUrl] - Required by the BullMQ driver.
|
|
32
|
+
* @param {string} [options.prefix='app']
|
|
33
|
+
* @param {object} [options.logger]
|
|
34
|
+
* @param {object} [options.defaults] - `attempts`, `backoff`, `concurrency`.
|
|
35
|
+
* @return {object} The queue: define, enqueue, start, stop, idle and state.
|
|
36
|
+
*/
|
|
37
|
+
export function createJobQueue({
|
|
38
|
+
driver = 'memory',
|
|
39
|
+
redisUrl,
|
|
40
|
+
prefix = 'app',
|
|
41
|
+
logger = noopLogger,
|
|
42
|
+
defaults = {},
|
|
43
|
+
} = {}) {
|
|
44
|
+
const createBackend = BACKENDS[driver];
|
|
45
|
+
if (!createBackend) {
|
|
46
|
+
throw new Error(`unknown job driver "${driver}", expected ${Object.keys(BACKENDS).join(' or ')}`);
|
|
47
|
+
}
|
|
48
|
+
const merged = Object.freeze({
|
|
49
|
+
attempts: defaults.attempts ?? DEFAULTS.attempts,
|
|
50
|
+
backoff: normalizeBackoff(defaults.backoff ?? DEFAULTS.backoff),
|
|
51
|
+
concurrency: defaults.concurrency ?? DEFAULTS.concurrency,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const definitions = new Map();
|
|
55
|
+
const backend = createBackend({ redisUrl, prefix, logger });
|
|
56
|
+
let state = 'idle';
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Declares a job before start(); the name must be unique.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} name
|
|
62
|
+
* @param {(payload: *, context: object) => (void|Promise<void>)} handler
|
|
63
|
+
* @param {object} [options] - Per-job overrides: `attempts`, `backoff`, `concurrency`.
|
|
64
|
+
* @return {void}
|
|
65
|
+
*/
|
|
66
|
+
function define(name, handler, options = {}) {
|
|
67
|
+
if (state !== 'idle') throw new Error('jobs must be declared before start()');
|
|
68
|
+
const jobName = assertName(name);
|
|
69
|
+
assertHandler(jobName, handler);
|
|
70
|
+
if (definitions.has(jobName)) throw new Error(`job "${jobName}" is already declared`);
|
|
71
|
+
definitions.set(jobName, { handler, ...resolveDefinition(merged, options) });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Enqueues a job by name; the name need not be declared in this process.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} name
|
|
78
|
+
* @param {*} payload - Passed to the handler as its first argument.
|
|
79
|
+
* @param {object} options
|
|
80
|
+
* @param {string} options.key - Required, non-empty idempotency key.
|
|
81
|
+
* @param {number} [options.delay=0] - Milliseconds before the job becomes runnable.
|
|
82
|
+
* @return {Promise<{jobId: string, name: string, key: string}>}
|
|
83
|
+
*/
|
|
84
|
+
async function enqueue(name, payload, { key: givenKey, delay = 0 } = {}) {
|
|
85
|
+
// No define()/start() required: a producer-only process enqueues by name.
|
|
86
|
+
if (state === 'stopping' || state === 'stopped') {
|
|
87
|
+
throw new Error('cannot enqueue on a stopped job queue');
|
|
88
|
+
}
|
|
89
|
+
const jobName = assertName(name);
|
|
90
|
+
const key = assertKey(givenKey);
|
|
91
|
+
assertDuration('delay', delay);
|
|
92
|
+
// A producer that never declared the job takes its retry policy from
|
|
93
|
+
// `defaults`; per-job overrides live on the consumer's define().
|
|
94
|
+
const policy = definitions.get(jobName) ?? merged;
|
|
95
|
+
const jobId = await backend.add(jobName, payload, {
|
|
96
|
+
key,
|
|
97
|
+
dedupId: dedupId(jobName, key),
|
|
98
|
+
attempts: policy.attempts,
|
|
99
|
+
backoff: policy.backoff,
|
|
100
|
+
delay,
|
|
101
|
+
});
|
|
102
|
+
return Object.freeze({ jobId, name: jobName, key });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function start() {
|
|
106
|
+
if (state === 'started') return;
|
|
107
|
+
if (state === 'stopped') throw new Error('a stopped job queue cannot be restarted');
|
|
108
|
+
if (state !== 'idle') throw new Error('the job queue is already changing state');
|
|
109
|
+
state = 'starting';
|
|
110
|
+
// allSettled lets every worker register before a failure surfaces, so a
|
|
111
|
+
// rejection mid-start can't leak the workers its siblings already created.
|
|
112
|
+
const results = await Promise.allSettled(
|
|
113
|
+
[...definitions].map(([name, definition]) => backend.work(name, definition)),
|
|
114
|
+
);
|
|
115
|
+
const failed = results.find((result) => result.status === 'rejected');
|
|
116
|
+
if (failed) {
|
|
117
|
+
state = 'stopping';
|
|
118
|
+
await backend.stop({});
|
|
119
|
+
state = 'stopped';
|
|
120
|
+
throw failed.reason;
|
|
121
|
+
}
|
|
122
|
+
state = 'started';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Stops intake and drains in-flight jobs.
|
|
127
|
+
*
|
|
128
|
+
* @param {object} [options]
|
|
129
|
+
* @param {number} [options.timeoutMs=30000] - Drain deadline before abandoning
|
|
130
|
+
* in-flight jobs (memory driver; the BullMQ driver lets BullMQ own the deadline).
|
|
131
|
+
* @return {Promise<void>}
|
|
132
|
+
*/
|
|
133
|
+
async function stop(options = {}) {
|
|
134
|
+
if (state === 'stopped') return;
|
|
135
|
+
state = 'stopping';
|
|
136
|
+
await backend.stop(options);
|
|
137
|
+
state = 'stopped';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Resolves once nothing is delayed, queued or running. */
|
|
141
|
+
function idle() {
|
|
142
|
+
return backend.idle();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return Object.freeze({
|
|
146
|
+
driver,
|
|
147
|
+
define,
|
|
148
|
+
enqueue,
|
|
149
|
+
start,
|
|
150
|
+
stop,
|
|
151
|
+
idle,
|
|
152
|
+
get state() {
|
|
153
|
+
return state;
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
}
|
package/log/index.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import pino from 'pino';
|
|
2
|
+
import { cleanStack } from '../errors/stack.js';
|
|
3
|
+
|
|
4
|
+
// Wraps pino's std err serializer so every `{ err }` log line carries
|
|
5
|
+
// repo-relative frames instead of absolute, machine-specific paths. The cause
|
|
6
|
+
// chain the std serializer attaches is cleaned too.
|
|
7
|
+
function errSerializer(error) {
|
|
8
|
+
const serialized = pino.stdSerializers.err(error);
|
|
9
|
+
if (serialized?.stack) serialized.stack = cleanStack(serialized.stack);
|
|
10
|
+
if (serialized?.cause?.stack) serialized.cause.stack = cleanStack(serialized.cause.stack);
|
|
11
|
+
return serialized;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Redaction is configured once, at logger creation — never at the call site.
|
|
15
|
+
//
|
|
16
|
+
// pino's `*` matches exactly one level, so a key has to be listed per depth.
|
|
17
|
+
// Three tiers cover `{ password }`, `{ body: { password } }` and
|
|
18
|
+
// `{ req: { body: { password } } }` — the shapes a handler actually logs.
|
|
19
|
+
const SECRET_KEYS = [
|
|
20
|
+
'password',
|
|
21
|
+
'passwordHash',
|
|
22
|
+
'token',
|
|
23
|
+
'tokenHash',
|
|
24
|
+
'accessToken',
|
|
25
|
+
'refreshToken',
|
|
26
|
+
'secret',
|
|
27
|
+
'apiKey',
|
|
28
|
+
'authorization',
|
|
29
|
+
'cookie',
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
export const DEFAULT_REDACT_PATHS = Object.freeze(
|
|
33
|
+
SECRET_KEYS.flatMap((key) => [key, `*.${key}`, `*.*.${key}`]),
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Closed vocabulary for the `type` field, the discriminator that lets a log
|
|
38
|
+
* store split request, event, job and integration lines apart after the fact.
|
|
39
|
+
*/
|
|
40
|
+
export const LOG_TYPE = Object.freeze({
|
|
41
|
+
REQUEST: 'request',
|
|
42
|
+
EVENT: 'event',
|
|
43
|
+
JOB: 'job',
|
|
44
|
+
SCHEDULE: 'schedule',
|
|
45
|
+
INTEGRATION: 'integration',
|
|
46
|
+
LIFECYCLE: 'lifecycle',
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Whether `pino-pretty` can be loaded, so a `pretty` request degrades to JSON
|
|
51
|
+
* instead of throwing when the optional peer is absent.
|
|
52
|
+
*
|
|
53
|
+
* @return {boolean}
|
|
54
|
+
*/
|
|
55
|
+
export function prettyAvailable() {
|
|
56
|
+
try {
|
|
57
|
+
import.meta.resolve('pino-pretty');
|
|
58
|
+
return true;
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Builds the pino transport for human-readable output, or `undefined` for the
|
|
66
|
+
* JSON default. Exported so it can be asserted without spawning a worker thread.
|
|
67
|
+
*
|
|
68
|
+
* @param {boolean|object} [pretty=false] - `true` for the defaults, or pino-pretty options to merge.
|
|
69
|
+
* @return {object|undefined} A pino `transport` option, or undefined.
|
|
70
|
+
*/
|
|
71
|
+
export function prettyTransport(pretty = false) {
|
|
72
|
+
if (!pretty) return undefined;
|
|
73
|
+
return {
|
|
74
|
+
target: 'pino-pretty',
|
|
75
|
+
options: {
|
|
76
|
+
translateTime: 'HH:MM:ss.l',
|
|
77
|
+
ignore: 'pid,hostname',
|
|
78
|
+
...(pretty === true ? {} : pretty),
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Creates the process logger. Everything that shapes it arrives as data — the
|
|
85
|
+
* level, where it writes and which transport carries the lines are the app's
|
|
86
|
+
* decisions, not the environment's.
|
|
87
|
+
*
|
|
88
|
+
* @param {object} [options]
|
|
89
|
+
* @param {string} [options.level='info'] - pino level.
|
|
90
|
+
* @param {readonly string[]} [options.redact] - Paths to censor. Defaults to `DEFAULT_REDACT_PATHS`.
|
|
91
|
+
* @param {string} [options.censor='[redacted]'] - Replacement for redacted values.
|
|
92
|
+
* @param {object} [options.transport] - A pino `transport` (single `{target}` or `{targets:[…]}`). Wins over `pretty`.
|
|
93
|
+
* @param {boolean|object} [options.pretty=false] - Convenience `pino-pretty` transport; needs the optional peer. Ignored when `transport` is set.
|
|
94
|
+
* @param {object} [options.base] - Bindings on every line. Omitted leaves pino's default.
|
|
95
|
+
* @param {import('node:stream').Writable} [options.destination] - Where to write. Cannot combine with a transport.
|
|
96
|
+
* @param {{get: Function}} [options.context] - A context store; its fields ride on every line as a mixin.
|
|
97
|
+
* @param {object} [options.serializers] - Merged over the default `err` serializer.
|
|
98
|
+
* @param {object} [options.pinoOptions] - Escape hatch, merged last.
|
|
99
|
+
* @return {import('pino').Logger} The logger.
|
|
100
|
+
*/
|
|
101
|
+
export function createLogger({
|
|
102
|
+
level = 'info',
|
|
103
|
+
redact = DEFAULT_REDACT_PATHS,
|
|
104
|
+
censor = '[redacted]',
|
|
105
|
+
transport,
|
|
106
|
+
pretty = false,
|
|
107
|
+
base,
|
|
108
|
+
destination,
|
|
109
|
+
context,
|
|
110
|
+
serializers,
|
|
111
|
+
pinoOptions = {},
|
|
112
|
+
} = {}) {
|
|
113
|
+
// An explicit transport is the caller's to get right; the `pretty` convenience
|
|
114
|
+
// degrades to JSON when its optional peer is missing instead of crashing.
|
|
115
|
+
const activeTransport = transport ?? (pretty && prettyAvailable() ? prettyTransport(pretty) : undefined);
|
|
116
|
+
|
|
117
|
+
// pino writes a transport from a worker thread and a `destination` from the
|
|
118
|
+
// main one; wiring both leaves two sinks fighting over the same logger.
|
|
119
|
+
if (activeTransport && destination) {
|
|
120
|
+
throw new Error('createLogger: a transport (including `pretty`) cannot combine with `destination`');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const paths = [...redact];
|
|
124
|
+
const { mixin: applicationMixin, ...remainingPinoOptions } = pinoOptions;
|
|
125
|
+
|
|
126
|
+
// The context store's fields (requestId, correlationId) ride on every line so
|
|
127
|
+
// a correlation id survives into the log without the call site repeating it.
|
|
128
|
+
const mixin = context || applicationMixin
|
|
129
|
+
? (...args) => ({
|
|
130
|
+
...(context?.get() ?? {}),
|
|
131
|
+
...(applicationMixin?.(...args) ?? {}),
|
|
132
|
+
})
|
|
133
|
+
: undefined;
|
|
134
|
+
|
|
135
|
+
const options = {
|
|
136
|
+
level,
|
|
137
|
+
...(paths.length > 0 ? { redact: { paths, censor } } : {}),
|
|
138
|
+
...(base === undefined ? {} : { base }),
|
|
139
|
+
// `{ err }` is the shape the kit's error handler logs; the serializer turns
|
|
140
|
+
// it into type/message/stack, with paths relativized to the cwd.
|
|
141
|
+
serializers: { err: errSerializer, ...serializers },
|
|
142
|
+
...(activeTransport ? { transport: activeTransport } : {}),
|
|
143
|
+
...(mixin ? { mixin } : {}),
|
|
144
|
+
...remainingPinoOptions,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
return destination ? pino(options, destination) : pino(options);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Stamps a category (and optional bindings) onto a child logger, so every line
|
|
152
|
+
* from that scope carries the same `type` for later filtering.
|
|
153
|
+
*
|
|
154
|
+
* @param {import('pino').Logger} logger
|
|
155
|
+
* @param {string} type - One of `LOG_TYPE`.
|
|
156
|
+
* @param {object} [bindings] - Extra fields fixed on the child, e.g. `{ jobName }`.
|
|
157
|
+
* @return {import('pino').Logger} The child logger.
|
|
158
|
+
*/
|
|
159
|
+
export function withType(logger, type, bindings = {}) {
|
|
160
|
+
return logger.child({ type, ...bindings });
|
|
161
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@devindex/api-kit",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Building blocks for Fastify services: typed domain errors, HTTP plugins, logging and background runtime",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
"./context": "./context/index.js",
|
|
8
|
+
"./errors": "./errors/index.js",
|
|
9
|
+
"./events": "./events/index.js",
|
|
10
|
+
"./http": "./http/index.js",
|
|
11
|
+
"./jobs": "./jobs/index.js",
|
|
12
|
+
"./log": "./log/index.js",
|
|
13
|
+
"./schedule": "./schedule/index.js",
|
|
14
|
+
"./runtime": "./runtime/index.js",
|
|
15
|
+
"./package.json": "./package.json"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"context",
|
|
19
|
+
"errors",
|
|
20
|
+
"events",
|
|
21
|
+
"http",
|
|
22
|
+
"internal",
|
|
23
|
+
"jobs",
|
|
24
|
+
"log",
|
|
25
|
+
"runtime",
|
|
26
|
+
"schedule"
|
|
27
|
+
],
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=22"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"test": "node --test \"tests/*.test.js\"",
|
|
33
|
+
"test:redis": "REQUIRE_REDIS=1 node --test \"tests/*.test.js\""
|
|
34
|
+
},
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/devindex/packages.git",
|
|
38
|
+
"directory": "packages/api-kit"
|
|
39
|
+
},
|
|
40
|
+
"keywords": [
|
|
41
|
+
"fastify",
|
|
42
|
+
"api",
|
|
43
|
+
"backend",
|
|
44
|
+
"errors",
|
|
45
|
+
"jobs",
|
|
46
|
+
"bullmq",
|
|
47
|
+
"cron",
|
|
48
|
+
"schedule",
|
|
49
|
+
"events",
|
|
50
|
+
"event-bus",
|
|
51
|
+
"pubsub"
|
|
52
|
+
],
|
|
53
|
+
"peerDependencies": {
|
|
54
|
+
"@fastify/cors": "^11.0.0",
|
|
55
|
+
"@fastify/helmet": "^13.0.0",
|
|
56
|
+
"bullmq": "^6.0.8",
|
|
57
|
+
"croner": "^10.0.1",
|
|
58
|
+
"fastify": "^5.11.0",
|
|
59
|
+
"fastify-plugin": "^6.0.0",
|
|
60
|
+
"ioredis": "^5.7.0",
|
|
61
|
+
"pino": "^10.0.0",
|
|
62
|
+
"pino-pretty": "^13.0.0"
|
|
63
|
+
},
|
|
64
|
+
"peerDependenciesMeta": {
|
|
65
|
+
"@fastify/cors": {
|
|
66
|
+
"optional": true
|
|
67
|
+
},
|
|
68
|
+
"@fastify/helmet": {
|
|
69
|
+
"optional": true
|
|
70
|
+
},
|
|
71
|
+
"bullmq": {
|
|
72
|
+
"optional": true
|
|
73
|
+
},
|
|
74
|
+
"croner": {
|
|
75
|
+
"optional": true
|
|
76
|
+
},
|
|
77
|
+
"fastify": {
|
|
78
|
+
"optional": true
|
|
79
|
+
},
|
|
80
|
+
"fastify-plugin": {
|
|
81
|
+
"optional": true
|
|
82
|
+
},
|
|
83
|
+
"ioredis": {
|
|
84
|
+
"optional": true
|
|
85
|
+
},
|
|
86
|
+
"pino": {
|
|
87
|
+
"optional": true
|
|
88
|
+
},
|
|
89
|
+
"pino-pretty": {
|
|
90
|
+
"optional": true
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
"devDependencies": {
|
|
94
|
+
"@fastify/cors": "^11.0.0",
|
|
95
|
+
"@fastify/helmet": "^13.0.0",
|
|
96
|
+
"bullmq": "^6.0.8",
|
|
97
|
+
"croner": "^10.0.1",
|
|
98
|
+
"fastify": "^5.11.0",
|
|
99
|
+
"fastify-plugin": "^6.0.0",
|
|
100
|
+
"ioredis": "^5.7.0",
|
|
101
|
+
"pino": "^10.0.0",
|
|
102
|
+
"pino-pretty": "^13.0.0"
|
|
103
|
+
},
|
|
104
|
+
"author": "Sergio Rodrigues <exprodrigues@gmail.com>",
|
|
105
|
+
"bugs": {
|
|
106
|
+
"url": "https://github.com/devindex/packages/issues"
|
|
107
|
+
},
|
|
108
|
+
"homepage": "https://github.com/devindex/packages/tree/main/packages/api-kit#readme",
|
|
109
|
+
"license": "MIT"
|
|
110
|
+
}
|
package/runtime/index.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs `close` once on the first termination signal, then exits the process.
|
|
3
|
+
*
|
|
4
|
+
* @param {(signal: string) => (void | Promise<void>)} close - The teardown to run.
|
|
5
|
+
* @param {object} [options]
|
|
6
|
+
* @param {string[]} [options.signals=['SIGINT','SIGTERM']] - Signals that trigger it.
|
|
7
|
+
* @param {number} [options.timeoutMs=10000] - Hard deadline; a hung `close` force-exits with 1.
|
|
8
|
+
* @param {{ error: Function }} [options.logger] - Logs the failure before exiting with 1.
|
|
9
|
+
*/
|
|
10
|
+
export function onShutdown(close, { signals = ['SIGINT', 'SIGTERM'], timeoutMs = 10_000, logger } = {}) {
|
|
11
|
+
let running = false;
|
|
12
|
+
|
|
13
|
+
const handle = async (signal) => {
|
|
14
|
+
// A second signal mid-drain must not run `close` twice.
|
|
15
|
+
if (running) return;
|
|
16
|
+
running = true;
|
|
17
|
+
|
|
18
|
+
// Don't unref this timer: a hung `close` that stops pinning the event loop
|
|
19
|
+
// would then let the process exit 0, reporting success for a failed shutdown.
|
|
20
|
+
const deadline = setTimeout(() => process.exit(1), timeoutMs);
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
await close(signal);
|
|
24
|
+
clearTimeout(deadline);
|
|
25
|
+
process.exit(0);
|
|
26
|
+
} catch (error) {
|
|
27
|
+
clearTimeout(deadline);
|
|
28
|
+
logger?.error({ err: error }, 'shutdown failed');
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Wiring the signal is all this owns; teardown order stays inside `close`.
|
|
34
|
+
for (const signal of signals) {
|
|
35
|
+
process.once(signal, () => handle(signal));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import {
|
|
2
|
+
loadBullmq,
|
|
3
|
+
queueName,
|
|
4
|
+
redisConnection,
|
|
5
|
+
} from '../../internal/bullmq.js';
|
|
6
|
+
import { noopLogger } from '../../internal/logger.js';
|
|
7
|
+
|
|
8
|
+
const SCHEDULER_ID = 'schedule';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Distributed backend on BullMQ's Job Scheduler, one queue per schedule name.
|
|
12
|
+
* Every replica upserts the same leaderless scheduler and starts an equivalent
|
|
13
|
+
* worker; BullMQ coordinates which worker receives each occurrence. Redis is only
|
|
14
|
+
* its store.
|
|
15
|
+
*
|
|
16
|
+
* @param {object} options
|
|
17
|
+
* @param {string} options.redisUrl
|
|
18
|
+
* @param {string} [options.prefix='app']
|
|
19
|
+
* @param {object} [options.logger]
|
|
20
|
+
* @return {object} A backend: schedule, unschedule and stop.
|
|
21
|
+
*/
|
|
22
|
+
export function bullmqBackend({ redisUrl, prefix = 'app', logger = noopLogger } = {}) {
|
|
23
|
+
const queueConnection = redisConnection(redisUrl);
|
|
24
|
+
const workerConnection = redisConnection(redisUrl, { worker: true });
|
|
25
|
+
const queuePromises = new Map();
|
|
26
|
+
const workers = new Map();
|
|
27
|
+
|
|
28
|
+
/** Get or create the per-name schedule queue: single attempt, small retention. */
|
|
29
|
+
function getQueue(name) {
|
|
30
|
+
let promise = queuePromises.get(name);
|
|
31
|
+
if (!promise) {
|
|
32
|
+
promise = (async () => {
|
|
33
|
+
const { Queue } = await loadBullmq();
|
|
34
|
+
return new Queue(queueName(prefix, 'schedule', name), {
|
|
35
|
+
connection: queueConnection,
|
|
36
|
+
defaultJobOptions: {
|
|
37
|
+
attempts: 1,
|
|
38
|
+
removeOnComplete: { age: 24 * 60 * 60, count: 100 },
|
|
39
|
+
removeOnFail: { age: 7 * 24 * 60 * 60 },
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
})();
|
|
43
|
+
queuePromises.set(name, promise);
|
|
44
|
+
}
|
|
45
|
+
return promise;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Remove the shared Job Scheduler from every queue this replica opened. */
|
|
49
|
+
async function removeAllSchedulers() {
|
|
50
|
+
await Promise.allSettled([...queuePromises.values()].map(async (queuePromise) => {
|
|
51
|
+
const queue = await queuePromise;
|
|
52
|
+
await queue.removeJobScheduler(SCHEDULER_ID);
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Close every worker and queue this replica opened, logging each failure. */
|
|
57
|
+
async function closeResources() {
|
|
58
|
+
for (const worker of workers.values()) worker.cancelAllJobs(new Error('schedule is stopping'));
|
|
59
|
+
const workerResults = await Promise.allSettled([...workers.values()].map((worker) => worker.close()));
|
|
60
|
+
const queueResults = await Promise.allSettled([...queuePromises.values()]);
|
|
61
|
+
const closeResults = await Promise.allSettled(
|
|
62
|
+
queueResults.flatMap((result) => (result.status === 'fulfilled' ? [result.value.close()] : [])),
|
|
63
|
+
);
|
|
64
|
+
for (const result of [...workerResults, ...queueResults, ...closeResults]) {
|
|
65
|
+
if (result.status === 'rejected') logger.error({ err: result.reason }, 'schedule close failed');
|
|
66
|
+
}
|
|
67
|
+
workers.clear();
|
|
68
|
+
queuePromises.clear();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
/** Start a worker and upsert the leaderless Job Scheduler for one schedule. */
|
|
73
|
+
async schedule(name, definition) {
|
|
74
|
+
const { Worker } = await loadBullmq();
|
|
75
|
+
const queue = await getQueue(name);
|
|
76
|
+
await queue.setGlobalConcurrency(1);
|
|
77
|
+
const worker = new Worker(queue.name, async (job, _token, signal) => {
|
|
78
|
+
if (job.name !== name) throw new Error(`schedule "${job.name}" reached the worker for "${name}"`);
|
|
79
|
+
const log = logger.child({ schedule: name, runId: job.id });
|
|
80
|
+
try {
|
|
81
|
+
return await definition.handler({ name, signal, log });
|
|
82
|
+
} catch (error) {
|
|
83
|
+
logger.error({ err: error, schedule: name, runId: job.id }, 'schedule failed');
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}, { connection: workerConnection, concurrency: 1 });
|
|
87
|
+
workers.set(name, worker);
|
|
88
|
+
worker.on('error', (error) => logger.error({ err: error, schedule: name }, 'schedule worker error'));
|
|
89
|
+
worker.on('stalled', (jobId) => {
|
|
90
|
+
logger.error({ schedule: name, runId: jobId }, 'schedule stalled and will be delivered again');
|
|
91
|
+
});
|
|
92
|
+
await worker.waitUntilReady();
|
|
93
|
+
await queue.upsertJobScheduler(
|
|
94
|
+
SCHEDULER_ID,
|
|
95
|
+
{ pattern: definition.spec.pattern, tz: definition.spec.timeZone },
|
|
96
|
+
{ name, data: {} },
|
|
97
|
+
);
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
/** Remove one schedule's Job Scheduler and close its local worker and queue. */
|
|
101
|
+
async unschedule(name) {
|
|
102
|
+
// Before start() (or after stop()) nothing exists in Redis yet, so dropping
|
|
103
|
+
// the declaration is enough — this mirrors the memory backend's inert removal.
|
|
104
|
+
if (!queuePromises.has(name)) return;
|
|
105
|
+
const queue = await getQueue(name);
|
|
106
|
+
await queue.removeJobScheduler(SCHEDULER_ID);
|
|
107
|
+
const worker = workers.get(name);
|
|
108
|
+
if (worker) {
|
|
109
|
+
worker.cancelAllJobs(new Error(`schedule "${name}" was removed`));
|
|
110
|
+
await worker.close();
|
|
111
|
+
workers.delete(name);
|
|
112
|
+
}
|
|
113
|
+
await queue.close();
|
|
114
|
+
queuePromises.delete(name);
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
// BullMQ owns the drain deadline once worker.close() starts, so `timeoutMs` is
|
|
118
|
+
// accepted for a uniform signature but not used here.
|
|
119
|
+
async stop({ removeSchedulers = false } = {}) {
|
|
120
|
+
// A failed start owns the schedulers it upserted and removes them, or they
|
|
121
|
+
// orphan jobs into queues no worker drains; a normal stop leaves the shared
|
|
122
|
+
// scheduler so other replicas keep it (leaderless).
|
|
123
|
+
if (removeSchedulers) await removeAllSchedulers();
|
|
124
|
+
await closeResources();
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Cron } from 'croner';
|
|
2
|
+
import { noopLogger } from '../../internal/logger.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* In-process backend on Croner. Runs each schedule directly in this process,
|
|
6
|
+
* skipping a tick while its previous run is still active, and keeps nothing once
|
|
7
|
+
* the process exits.
|
|
8
|
+
*
|
|
9
|
+
* @param {object} [options]
|
|
10
|
+
* @param {object} [options.logger]
|
|
11
|
+
* @return {object} A backend: schedule, unschedule and stop.
|
|
12
|
+
*/
|
|
13
|
+
export function memoryBackend({ logger = noopLogger } = {}) {
|
|
14
|
+
const clocks = new Map();
|
|
15
|
+
const active = new Map();
|
|
16
|
+
|
|
17
|
+
/** Run one tick unless the previous run is still active; each run gets its own abort signal. */
|
|
18
|
+
function run(name, definition) {
|
|
19
|
+
if (active.has(name)) {
|
|
20
|
+
logger.warn({ schedule: name }, 'schedule tick skipped because the previous run is active');
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const controller = new AbortController();
|
|
24
|
+
const log = logger.child({ schedule: name });
|
|
25
|
+
const promise = Promise.resolve()
|
|
26
|
+
.then(() => definition.handler({ name, signal: controller.signal, log }))
|
|
27
|
+
.catch((error) => logger.error({ err: error, schedule: name }, 'schedule failed'))
|
|
28
|
+
.finally(() => active.delete(name));
|
|
29
|
+
active.set(name, { controller, promise });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
/** Start a croner clock that fires run() on each occurrence. */
|
|
34
|
+
async schedule(name, definition) {
|
|
35
|
+
const clock = new Cron(
|
|
36
|
+
definition.spec.pattern,
|
|
37
|
+
{ name, timezone: definition.spec.timeZone },
|
|
38
|
+
() => run(name, definition),
|
|
39
|
+
);
|
|
40
|
+
clocks.set(name, clock);
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
/** Stop future ticks for one schedule and drop its clock. */
|
|
44
|
+
async unschedule(name) {
|
|
45
|
+
clocks.get(name)?.stop();
|
|
46
|
+
clocks.delete(name);
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
// No drain deadline today: aborted runs are awaited unbounded, so `timeoutMs`
|
|
50
|
+
// is ignored. `removeSchedulers` has no meaning in-process — there is no shared
|
|
51
|
+
// scheduler, only local clocks, which stop either way.
|
|
52
|
+
async stop() {
|
|
53
|
+
for (const clock of clocks.values()) clock.stop();
|
|
54
|
+
clocks.clear();
|
|
55
|
+
for (const entry of active.values()) entry.controller.abort();
|
|
56
|
+
await Promise.allSettled([...active.values()].map((entry) => entry.promise));
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|