@pipeline-builder/api-server 0.0.1
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 +202 -0
- package/README.md +79 -0
- package/lib/api/app-factory.d.ts +95 -0
- package/lib/api/app-factory.js +328 -0
- package/lib/api/check-quota.d.ts +13 -0
- package/lib/api/check-quota.js +74 -0
- package/lib/api/context-middleware.d.ts +34 -0
- package/lib/api/context-middleware.js +42 -0
- package/lib/api/etag-middleware.d.ts +7 -0
- package/lib/api/etag-middleware.js +44 -0
- package/lib/api/get-context.d.ts +22 -0
- package/lib/api/get-context.js +28 -0
- package/lib/api/health-checks.d.ts +62 -0
- package/lib/api/health-checks.js +87 -0
- package/lib/api/idempotency-middleware.d.ts +80 -0
- package/lib/api/idempotency-middleware.js +321 -0
- package/lib/api/index.d.ts +20 -0
- package/lib/api/index.js +34 -0
- package/lib/api/meter-quota.d.ts +32 -0
- package/lib/api/meter-quota.js +70 -0
- package/lib/api/metrics.d.ts +47 -0
- package/lib/api/metrics.js +221 -0
- package/lib/api/middleware-factory.d.ts +56 -0
- package/lib/api/middleware-factory.js +80 -0
- package/lib/api/middleware.d.ts +1 -0
- package/lib/api/middleware.js +8 -0
- package/lib/api/mongo-connect.d.ts +19 -0
- package/lib/api/mongo-connect.js +41 -0
- package/lib/api/quota-helpers.d.ts +23 -0
- package/lib/api/quota-helpers.js +22 -0
- package/lib/api/rate-limit-by-org.d.ts +29 -0
- package/lib/api/rate-limit-by-org.js +64 -0
- package/lib/api/readiness.d.ts +29 -0
- package/lib/api/readiness.js +65 -0
- package/lib/api/request-types.d.ts +55 -0
- package/lib/api/request-types.js +65 -0
- package/lib/api/require-org-id.d.ts +14 -0
- package/lib/api/require-org-id.js +28 -0
- package/lib/api/route-wrapper.d.ts +50 -0
- package/lib/api/route-wrapper.js +84 -0
- package/lib/api/server.d.ts +79 -0
- package/lib/api/server.js +234 -0
- package/lib/api/tenant-context.d.ts +28 -0
- package/lib/api/tenant-context.js +48 -0
- package/lib/api/tracing.d.ts +25 -0
- package/lib/api/tracing.js +69 -0
- package/lib/http/index.d.ts +4 -0
- package/lib/http/index.js +7 -0
- package/lib/http/sse-connection-manager.d.ts +357 -0
- package/lib/http/sse-connection-manager.js +665 -0
- package/lib/http/sse-relay.d.ts +63 -0
- package/lib/http/sse-relay.js +70 -0
- package/lib/http/sse-ticket-channel.d.ts +31 -0
- package/lib/http/sse-ticket-channel.js +52 -0
- package/lib/http/sse-ticket-store.d.ts +75 -0
- package/lib/http/sse-ticket-store.js +160 -0
- package/lib/index.d.ts +30 -0
- package/lib/index.js +35 -0
- package/lib/otel-bootstrap.d.ts +1 -0
- package/lib/otel-bootstrap.js +75 -0
- package/package.json +183 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Copyright 2026 Pipeline Builder Contributors
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import { createHash } from 'crypto';
|
|
4
|
+
/**
|
|
5
|
+
* Middleware that adds ETag support for conditional GET requests.
|
|
6
|
+
* Generates a weak ETag from the response body hash.
|
|
7
|
+
* Returns 304 Not Modified when the client's If-None-Match header matches.
|
|
8
|
+
*/
|
|
9
|
+
export function etagMiddleware() {
|
|
10
|
+
return (req, res, next) => {
|
|
11
|
+
// Only apply to GET requests
|
|
12
|
+
if (req.method !== 'GET')
|
|
13
|
+
return next();
|
|
14
|
+
res.json = (body) => {
|
|
15
|
+
// Serialize once, hash, and send — avoids double JSON.stringify
|
|
16
|
+
const serialized = JSON.stringify(body);
|
|
17
|
+
// Only ETag/304 SUCCESSFUL responses. For non-2xx (errors), skip the
|
|
18
|
+
// conditional logic entirely — otherwise an error response whose body
|
|
19
|
+
// happens to hash-match the client's If-None-Match would be turned into a
|
|
20
|
+
// 304 Not Modified, making the client serve a stale (possibly successful)
|
|
21
|
+
// cached body in place of the error.
|
|
22
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
23
|
+
res.setHeader('Content-Type', 'application/json');
|
|
24
|
+
res.end(serialized);
|
|
25
|
+
return res;
|
|
26
|
+
}
|
|
27
|
+
const hash = createHash('md5').update(serialized).digest('hex').slice(0, 16);
|
|
28
|
+
const etag = `W/"${hash}"`;
|
|
29
|
+
res.setHeader('ETag', etag);
|
|
30
|
+
// Check If-None-Match header
|
|
31
|
+
const ifNoneMatch = req.headers['if-none-match'];
|
|
32
|
+
if (ifNoneMatch && ifNoneMatch === etag) {
|
|
33
|
+
res.status(304).end();
|
|
34
|
+
return res;
|
|
35
|
+
}
|
|
36
|
+
// Send pre-serialized body to avoid double JSON.stringify
|
|
37
|
+
res.setHeader('Content-Type', 'application/json');
|
|
38
|
+
res.end(serialized);
|
|
39
|
+
return res;
|
|
40
|
+
};
|
|
41
|
+
next();
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZXRhZy1taWRkbGV3YXJlLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vc3JjL2FwaS9ldGFnLW1pZGRsZXdhcmUudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsK0NBQStDO0FBQy9DLHNDQUFzQztBQUV0QyxPQUFPLEVBQUUsVUFBVSxFQUFFLE1BQU0sUUFBUSxDQUFDO0FBR3BDOzs7O0dBSUc7QUFDSCxNQUFNLFVBQVUsY0FBYztJQUM1QixPQUFPLENBQUMsR0FBWSxFQUFFLEdBQWEsRUFBRSxJQUFrQixFQUFFLEVBQUU7UUFDekQsNkJBQTZCO1FBQzdCLElBQUksR0FBRyxDQUFDLE1BQU0sS0FBSyxLQUFLO1lBQUUsT0FBTyxJQUFJLEVBQUUsQ0FBQztRQUV4QyxHQUFHLENBQUMsSUFBSSxHQUFHLENBQUMsSUFBYSxFQUFFLEVBQUU7WUFDM0IsZ0VBQWdFO1lBQ2hFLE1BQU0sVUFBVSxHQUFHLElBQUksQ0FBQyxTQUFTLENBQUMsSUFBSSxDQUFDLENBQUM7WUFFeEMscUVBQXFFO1lBQ3JFLHNFQUFzRTtZQUN0RSwwRUFBMEU7WUFDMUUsMEVBQTBFO1lBQzFFLHFDQUFxQztZQUNyQyxJQUFJLEdBQUcsQ0FBQyxVQUFVLEdBQUcsR0FBRyxJQUFJLEdBQUcsQ0FBQyxVQUFVLElBQUksR0FBRyxFQUFFLENBQUM7Z0JBQ2xELEdBQUcsQ0FBQyxTQUFTLENBQUMsY0FBYyxFQUFFLGtCQUFrQixDQUFDLENBQUM7Z0JBQ2xELEdBQUcsQ0FBQyxHQUFHLENBQUMsVUFBVSxDQUFDLENBQUM7Z0JBQ3BCLE9BQU8sR0FBRyxDQUFDO1lBQ2IsQ0FBQztZQUVELE1BQU0sSUFBSSxHQUFHLFVBQVUsQ0FBQyxLQUFLLENBQUMsQ0FBQyxNQUFNLENBQUMsVUFBVSxDQUFDLENBQUMsTUFBTSxDQUFDLEtBQUssQ0FBQyxDQUFDLEtBQUssQ0FBQyxDQUFDLEVBQUUsRUFBRSxDQUFDLENBQUM7WUFDN0UsTUFBTSxJQUFJLEdBQUcsTUFBTSxJQUFJLEdBQUcsQ0FBQztZQUUzQixHQUFHLENBQUMsU0FBUyxDQUFDLE1BQU0sRUFBRSxJQUFJLENBQUMsQ0FBQztZQUU1Qiw2QkFBNkI7WUFDN0IsTUFBTSxXQUFXLEdBQUcsR0FBRyxDQUFDLE9BQU8sQ0FBQyxlQUFlLENBQUMsQ0FBQztZQUNqRCxJQUFJLFdBQVcsSUFBSSxXQUFXLEtBQUssSUFBSSxFQUFFLENBQUM7Z0JBQ3hDLEdBQUcsQ0FBQyxNQUFNLENBQUMsR0FBRyxDQUFDLENBQUMsR0FBRyxFQUFFLENBQUM7Z0JBQ3RCLE9BQU8sR0FBRyxDQUFDO1lBQ2IsQ0FBQztZQUVELDBEQUEwRDtZQUMxRCxHQUFHLENBQUMsU0FBUyxDQUFDLGNBQWMsRUFBRSxrQkFBa0IsQ0FBQyxDQUFDO1lBQ2xELEdBQUcsQ0FBQyxHQUFHLENBQUMsVUFBVSxDQUFDLENBQUM7WUFDcEIsT0FBTyxHQUFHLENBQUM7UUFDYixDQUFDLENBQUM7UUFFRixJQUFJLEVBQUUsQ0FBQztJQUNULENBQUMsQ0FBQztBQUNKLENBQUMiLCJzb3VyY2VzQ29udGVudCI6WyIvLyBDb3B5cmlnaHQgMjAyNiBQaXBlbGluZSBCdWlsZGVyIENvbnRyaWJ1dG9yc1xuLy8gU1BEWC1MaWNlbnNlLUlkZW50aWZpZXI6IEFwYWNoZS0yLjBcblxuaW1wb3J0IHsgY3JlYXRlSGFzaCB9IGZyb20gJ2NyeXB0byc7XG5pbXBvcnQgdHlwZSB7IFJlcXVlc3QsIFJlc3BvbnNlLCBOZXh0RnVuY3Rpb24gfSBmcm9tICdleHByZXNzJztcblxuLyoqXG4gKiBNaWRkbGV3YXJlIHRoYXQgYWRkcyBFVGFnIHN1cHBvcnQgZm9yIGNvbmRpdGlvbmFsIEdFVCByZXF1ZXN0cy5cbiAqIEdlbmVyYXRlcyBhIHdlYWsgRVRhZyBmcm9tIHRoZSByZXNwb25zZSBib2R5IGhhc2guXG4gKiBSZXR1cm5zIDMwNCBOb3QgTW9kaWZpZWQgd2hlbiB0aGUgY2xpZW50J3MgSWYtTm9uZS1NYXRjaCBoZWFkZXIgbWF0Y2hlcy5cbiAqL1xuZXhwb3J0IGZ1bmN0aW9uIGV0YWdNaWRkbGV3YXJlKCkge1xuICByZXR1cm4gKHJlcTogUmVxdWVzdCwgcmVzOiBSZXNwb25zZSwgbmV4dDogTmV4dEZ1bmN0aW9uKSA9PiB7XG4gICAgLy8gT25seSBhcHBseSB0byBHRVQgcmVxdWVzdHNcbiAgICBpZiAocmVxLm1ldGhvZCAhPT0gJ0dFVCcpIHJldHVybiBuZXh0KCk7XG5cbiAgICByZXMuanNvbiA9IChib2R5OiB1bmtub3duKSA9PiB7XG4gICAgICAvLyBTZXJpYWxpemUgb25jZSwgaGFzaCwgYW5kIHNlbmQg4oCUIGF2b2lkcyBkb3VibGUgSlNPTi5zdHJpbmdpZnlcbiAgICAgIGNvbnN0IHNlcmlhbGl6ZWQgPSBKU09OLnN0cmluZ2lmeShib2R5KTtcblxuICAgICAgLy8gT25seSBFVGFnLzMwNCBTVUNDRVNTRlVMIHJlc3BvbnNlcy4gRm9yIG5vbi0yeHggKGVycm9ycyksIHNraXAgdGhlXG4gICAgICAvLyBjb25kaXRpb25hbCBsb2dpYyBlbnRpcmVseSDigJQgb3RoZXJ3aXNlIGFuIGVycm9yIHJlc3BvbnNlIHdob3NlIGJvZHlcbiAgICAgIC8vIGhhcHBlbnMgdG8gaGFzaC1tYXRjaCB0aGUgY2xpZW50J3MgSWYtTm9uZS1NYXRjaCB3b3VsZCBiZSB0dXJuZWQgaW50byBhXG4gICAgICAvLyAzMDQgTm90IE1vZGlmaWVkLCBtYWtpbmcgdGhlIGNsaWVudCBzZXJ2ZSBhIHN0YWxlIChwb3NzaWJseSBzdWNjZXNzZnVsKVxuICAgICAgLy8gY2FjaGVkIGJvZHkgaW4gcGxhY2Ugb2YgdGhlIGVycm9yLlxuICAgICAgaWYgKHJlcy5zdGF0dXNDb2RlIDwgMjAwIHx8IHJlcy5zdGF0dXNDb2RlID49IDMwMCkge1xuICAgICAgICByZXMuc2V0SGVhZGVyKCdDb250ZW50LVR5cGUnLCAnYXBwbGljYXRpb24vanNvbicpO1xuICAgICAgICByZXMuZW5kKHNlcmlhbGl6ZWQpO1xuICAgICAgICByZXR1cm4gcmVzO1xuICAgICAgfVxuXG4gICAgICBjb25zdCBoYXNoID0gY3JlYXRlSGFzaCgnbWQ1JykudXBkYXRlKHNlcmlhbGl6ZWQpLmRpZ2VzdCgnaGV4Jykuc2xpY2UoMCwgMTYpO1xuICAgICAgY29uc3QgZXRhZyA9IGBXL1wiJHtoYXNofVwiYDtcblxuICAgICAgcmVzLnNldEhlYWRlcignRVRhZycsIGV0YWcpO1xuXG4gICAgICAvLyBDaGVjayBJZi1Ob25lLU1hdGNoIGhlYWRlclxuICAgICAgY29uc3QgaWZOb25lTWF0Y2ggPSByZXEuaGVhZGVyc1snaWYtbm9uZS1tYXRjaCddO1xuICAgICAgaWYgKGlmTm9uZU1hdGNoICYmIGlmTm9uZU1hdGNoID09PSBldGFnKSB7XG4gICAgICAgIHJlcy5zdGF0dXMoMzA0KS5lbmQoKTtcbiAgICAgICAgcmV0dXJuIHJlcztcbiAgICAgIH1cblxuICAgICAgLy8gU2VuZCBwcmUtc2VyaWFsaXplZCBib2R5IHRvIGF2b2lkIGRvdWJsZSBKU09OLnN0cmluZ2lmeVxuICAgICAgcmVzLnNldEhlYWRlcignQ29udGVudC1UeXBlJywgJ2FwcGxpY2F0aW9uL2pzb24nKTtcbiAgICAgIHJlcy5lbmQoc2VyaWFsaXplZCk7XG4gICAgICByZXR1cm4gcmVzO1xuICAgIH07XG5cbiAgICBuZXh0KCk7XG4gIH07XG59XG4iXX0=
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Request } from 'express';
|
|
2
|
+
import type { RequestContext } from './request-types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Safely retrieve the RequestContext from an Express request.
|
|
5
|
+
*
|
|
6
|
+
* Throws a descriptive error if the context middleware has not been applied,
|
|
7
|
+
* instead of silently returning undefined or crashing with a cryptic error.
|
|
8
|
+
*
|
|
9
|
+
* @param req - Express request object
|
|
10
|
+
* @returns The attached RequestContext
|
|
11
|
+
* @throws Error if req.context is not initialized
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```typescript
|
|
15
|
+
* app.get('/pipelines', requireAuth, async (req, res) => {
|
|
16
|
+
* const ctx = getContext(req);
|
|
17
|
+
* ctx.log('INFO', 'Fetching pipelines');
|
|
18
|
+
* // ...
|
|
19
|
+
* });
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export declare function getContext(req: Request): RequestContext;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Copyright 2026 Pipeline Builder Contributors
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
/**
|
|
4
|
+
* Safely retrieve the RequestContext from an Express request.
|
|
5
|
+
*
|
|
6
|
+
* Throws a descriptive error if the context middleware has not been applied,
|
|
7
|
+
* instead of silently returning undefined or crashing with a cryptic error.
|
|
8
|
+
*
|
|
9
|
+
* @param req - Express request object
|
|
10
|
+
* @returns The attached RequestContext
|
|
11
|
+
* @throws Error if req.context is not initialized
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```typescript
|
|
15
|
+
* app.get('/pipelines', requireAuth, async (req, res) => {
|
|
16
|
+
* const ctx = getContext(req);
|
|
17
|
+
* ctx.log('INFO', 'Fetching pipelines');
|
|
18
|
+
* // ...
|
|
19
|
+
* });
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export function getContext(req) {
|
|
23
|
+
if (!req.context) {
|
|
24
|
+
throw new Error('Request context not initialized. Ensure attachRequestContext middleware is applied.');
|
|
25
|
+
}
|
|
26
|
+
return req.context;
|
|
27
|
+
}
|
|
28
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZ2V0LWNvbnRleHQuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi9zcmMvYXBpL2dldC1jb250ZXh0LnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLCtDQUErQztBQUMvQyxzQ0FBc0M7QUFLdEM7Ozs7Ozs7Ozs7Ozs7Ozs7OztHQWtCRztBQUNILE1BQU0sVUFBVSxVQUFVLENBQUMsR0FBWTtJQUNyQyxJQUFJLENBQUMsR0FBRyxDQUFDLE9BQU8sRUFBRSxDQUFDO1FBQ2pCLE1BQU0sSUFBSSxLQUFLLENBQ2IscUZBQXFGLENBQ3RGLENBQUM7SUFDSixDQUFDO0lBQ0QsT0FBTyxHQUFHLENBQUMsT0FBTyxDQUFDO0FBQ3JCLENBQUMiLCJzb3VyY2VzQ29udGVudCI6WyIvLyBDb3B5cmlnaHQgMjAyNiBQaXBlbGluZSBCdWlsZGVyIENvbnRyaWJ1dG9yc1xuLy8gU1BEWC1MaWNlbnNlLUlkZW50aWZpZXI6IEFwYWNoZS0yLjBcblxuaW1wb3J0IHR5cGUgeyBSZXF1ZXN0IH0gZnJvbSAnZXhwcmVzcyc7XG5pbXBvcnQgdHlwZSB7IFJlcXVlc3RDb250ZXh0IH0gZnJvbSAnLi9yZXF1ZXN0LXR5cGVzLmpzJztcblxuLyoqXG4gKiBTYWZlbHkgcmV0cmlldmUgdGhlIFJlcXVlc3RDb250ZXh0IGZyb20gYW4gRXhwcmVzcyByZXF1ZXN0LlxuICpcbiAqIFRocm93cyBhIGRlc2NyaXB0aXZlIGVycm9yIGlmIHRoZSBjb250ZXh0IG1pZGRsZXdhcmUgaGFzIG5vdCBiZWVuIGFwcGxpZWQsXG4gKiBpbnN0ZWFkIG9mIHNpbGVudGx5IHJldHVybmluZyB1bmRlZmluZWQgb3IgY3Jhc2hpbmcgd2l0aCBhIGNyeXB0aWMgZXJyb3IuXG4gKlxuICogQHBhcmFtIHJlcSAtIEV4cHJlc3MgcmVxdWVzdCBvYmplY3RcbiAqIEByZXR1cm5zIFRoZSBhdHRhY2hlZCBSZXF1ZXN0Q29udGV4dFxuICogQHRocm93cyBFcnJvciBpZiByZXEuY29udGV4dCBpcyBub3QgaW5pdGlhbGl6ZWRcbiAqXG4gKiBAZXhhbXBsZVxuICogYGBgdHlwZXNjcmlwdFxuICogYXBwLmdldCgnL3BpcGVsaW5lcycsIHJlcXVpcmVBdXRoLCBhc3luYyAocmVxLCByZXMpID0+IHtcbiAqICAgY29uc3QgY3R4ID0gZ2V0Q29udGV4dChyZXEpO1xuICogICBjdHgubG9nKCdJTkZPJywgJ0ZldGNoaW5nIHBpcGVsaW5lcycpO1xuICogICAvLyAuLi5cbiAqIH0pO1xuICogYGBgXG4gKi9cbmV4cG9ydCBmdW5jdGlvbiBnZXRDb250ZXh0KHJlcTogUmVxdWVzdCk6IFJlcXVlc3RDb250ZXh0IHtcbiAgaWYgKCFyZXEuY29udGV4dCkge1xuICAgIHRocm93IG5ldyBFcnJvcihcbiAgICAgICdSZXF1ZXN0IGNvbnRleHQgbm90IGluaXRpYWxpemVkLiBFbnN1cmUgYXR0YWNoUmVxdWVzdENvbnRleHQgbWlkZGxld2FyZSBpcyBhcHBsaWVkLicsXG4gICAgKTtcbiAgfVxuICByZXR1cm4gcmVxLmNvbnRleHQ7XG59XG4iXX0=
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
type HealthStatus = 'connected' | 'disconnected' | 'unknown';
|
|
2
|
+
type HealthResult = Record<string, HealthStatus>;
|
|
3
|
+
/**
|
|
4
|
+
* Postgres health probe: returns `{ postgres: 'connected' }` when the pool's
|
|
5
|
+
* connection test succeeds, `{ postgres: 'disconnected' }` on any error or
|
|
6
|
+
* timeout (so `/ready` correctly reports 503). Use as (part of) the
|
|
7
|
+
* `checkDependencies` option of `createApp()` for any service backed by the
|
|
8
|
+
* shared pipeline-data Drizzle connection.
|
|
9
|
+
*/
|
|
10
|
+
export declare function postgresHealthCheck(timeoutMs?: number): Promise<HealthResult>;
|
|
11
|
+
/** Minimal shape of an ioredis client needed for a health PING. */
|
|
12
|
+
interface RedisLike {
|
|
13
|
+
ping(): Promise<string>;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Source of a redis client: either the client itself, or a (sync/async) getter.
|
|
17
|
+
* The getter form supports BullMQ's `queue.client` (a `Promise<RedisClient>`)
|
|
18
|
+
* so a service can probe the exact connection its queue uses without opening
|
|
19
|
+
* a second one.
|
|
20
|
+
*/
|
|
21
|
+
type RedisClientSource = RedisLike | (() => RedisLike | Promise<RedisLike>);
|
|
22
|
+
/**
|
|
23
|
+
* Redis health probe factory: pass the service's EXISTING ioredis connection
|
|
24
|
+
* (or a getter for it — e.g. `() => queue.client`) and get back a
|
|
25
|
+
* `checkDependencies` callback that `PING`s it. Mirrors `mongoHealthCheck` —
|
|
26
|
+
* it probes the real connection rather than opening a throwaway one per call.
|
|
27
|
+
* The PING is time-boxed so a down redis (whose offline queue would otherwise
|
|
28
|
+
* buffer the command) reports `disconnected` quickly instead of hanging.
|
|
29
|
+
*/
|
|
30
|
+
export declare function redisHealthCheck(source: RedisClientSource, timeoutMs?: number): () => Promise<HealthResult>;
|
|
31
|
+
/**
|
|
32
|
+
* Combine several dependency probes into one `checkDependencies` callback that
|
|
33
|
+
* runs them IN PARALLEL and merges their results. A slow/failed probe can't
|
|
34
|
+
* delay the others or time out the whole `/ready` response.
|
|
35
|
+
*
|
|
36
|
+
* CONTRACT: each probe must CATCH internally and RETURN its `{ name: status }`
|
|
37
|
+
* (as the built-in postgres/redis/mongo probes do). A probe that THROWS is
|
|
38
|
+
* merged as `{}` — its dependency then vanishes from the map and `/ready`
|
|
39
|
+
* reports 200 with the dep simply absent (a silent false-healthy), rather than
|
|
40
|
+
* surfacing it as `disconnected`. The `.catch(() => ({}))` below only exists so
|
|
41
|
+
* one misbehaving probe can't take down the others, NOT as a substitute for a
|
|
42
|
+
* probe returning its own disconnected status.
|
|
43
|
+
*/
|
|
44
|
+
export declare function combineHealthChecks(...checks: Array<() => Promise<HealthResult>>): () => Promise<HealthResult>;
|
|
45
|
+
/**
|
|
46
|
+
* Minimal shape expected of a mongoose connection for the health probe.
|
|
47
|
+
* Accepts `mongoose.connection` directly without requiring mongoose as a
|
|
48
|
+
* dependency of api-server.
|
|
49
|
+
*/
|
|
50
|
+
interface MongooseConnectionLike {
|
|
51
|
+
readyState: number;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* MongoDB health probe factory: pass `mongoose.connection` and get back a
|
|
55
|
+
* `checkDependencies` callback that reports based on mongoose's readyState.
|
|
56
|
+
*
|
|
57
|
+
* Mapping (per mongoose docs): 1=connected, 2=connecting, 3=disconnecting,
|
|
58
|
+
* any other value (0=disconnected, 99=uninitialized) is treated as
|
|
59
|
+
* 'disconnected' so `/health` returns 503.
|
|
60
|
+
*/
|
|
61
|
+
export declare function mongoHealthCheck(connection: MongooseConnectionLike): () => Promise<HealthResult>;
|
|
62
|
+
export {};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Copyright 2026 Pipeline Builder Contributors
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import { getConnection } from '@pipeline-builder/pipeline-data';
|
|
4
|
+
/**
|
|
5
|
+
* Reject if `promise` hasn't settled within `ms`. A health/readiness probe
|
|
6
|
+
* must never hang the `/ready` response — a down datastore (or a BullMQ
|
|
7
|
+
* ioredis client with `maxRetriesPerRequest: null` + an offline queue) can
|
|
8
|
+
* leave a `ping()`/query pending indefinitely, so every probe is time-boxed.
|
|
9
|
+
*/
|
|
10
|
+
function withTimeout(promise, ms, label) {
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
|
|
13
|
+
timer.unref?.();
|
|
14
|
+
promise.then((value) => { clearTimeout(timer); resolve(value); }, (err) => { clearTimeout(timer); reject(err); });
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Postgres health probe: returns `{ postgres: 'connected' }` when the pool's
|
|
19
|
+
* connection test succeeds, `{ postgres: 'disconnected' }` on any error or
|
|
20
|
+
* timeout (so `/ready` correctly reports 503). Use as (part of) the
|
|
21
|
+
* `checkDependencies` option of `createApp()` for any service backed by the
|
|
22
|
+
* shared pipeline-data Drizzle connection.
|
|
23
|
+
*/
|
|
24
|
+
export async function postgresHealthCheck(timeoutMs = 3000) {
|
|
25
|
+
try {
|
|
26
|
+
await withTimeout(getConnection().testConnection(), timeoutMs, 'postgres testConnection');
|
|
27
|
+
return { postgres: 'connected' };
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return { postgres: 'disconnected' };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Redis health probe factory: pass the service's EXISTING ioredis connection
|
|
35
|
+
* (or a getter for it — e.g. `() => queue.client`) and get back a
|
|
36
|
+
* `checkDependencies` callback that `PING`s it. Mirrors `mongoHealthCheck` —
|
|
37
|
+
* it probes the real connection rather than opening a throwaway one per call.
|
|
38
|
+
* The PING is time-boxed so a down redis (whose offline queue would otherwise
|
|
39
|
+
* buffer the command) reports `disconnected` quickly instead of hanging.
|
|
40
|
+
*/
|
|
41
|
+
export function redisHealthCheck(source, timeoutMs = 2000) {
|
|
42
|
+
return async () => {
|
|
43
|
+
try {
|
|
44
|
+
const client = typeof source === 'function' ? await source() : source;
|
|
45
|
+
await withTimeout(client.ping(), timeoutMs, 'redis ping');
|
|
46
|
+
return { redis: 'connected' };
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return { redis: 'disconnected' };
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Combine several dependency probes into one `checkDependencies` callback that
|
|
55
|
+
* runs them IN PARALLEL and merges their results. A slow/failed probe can't
|
|
56
|
+
* delay the others or time out the whole `/ready` response.
|
|
57
|
+
*
|
|
58
|
+
* CONTRACT: each probe must CATCH internally and RETURN its `{ name: status }`
|
|
59
|
+
* (as the built-in postgres/redis/mongo probes do). A probe that THROWS is
|
|
60
|
+
* merged as `{}` — its dependency then vanishes from the map and `/ready`
|
|
61
|
+
* reports 200 with the dep simply absent (a silent false-healthy), rather than
|
|
62
|
+
* surfacing it as `disconnected`. The `.catch(() => ({}))` below only exists so
|
|
63
|
+
* one misbehaving probe can't take down the others, NOT as a substitute for a
|
|
64
|
+
* probe returning its own disconnected status.
|
|
65
|
+
*/
|
|
66
|
+
export function combineHealthChecks(...checks) {
|
|
67
|
+
return async () => {
|
|
68
|
+
const results = await Promise.all(checks.map((check) => check().catch(() => ({}))));
|
|
69
|
+
return Object.assign({}, ...results);
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* MongoDB health probe factory: pass `mongoose.connection` and get back a
|
|
74
|
+
* `checkDependencies` callback that reports based on mongoose's readyState.
|
|
75
|
+
*
|
|
76
|
+
* Mapping (per mongoose docs): 1=connected, 2=connecting, 3=disconnecting,
|
|
77
|
+
* any other value (0=disconnected, 99=uninitialized) is treated as
|
|
78
|
+
* 'disconnected' so `/health` returns 503.
|
|
79
|
+
*/
|
|
80
|
+
export function mongoHealthCheck(connection) {
|
|
81
|
+
return async () => ({
|
|
82
|
+
mongodb: connection.readyState === 1 ? 'connected'
|
|
83
|
+
: connection.readyState === 2 ? 'unknown' // connecting; treat as transient
|
|
84
|
+
: 'disconnected',
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaGVhbHRoLWNoZWNrcy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uL3NyYy9hcGkvaGVhbHRoLWNoZWNrcy50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSwrQ0FBK0M7QUFDL0Msc0NBQXNDO0FBRXRDLE9BQU8sRUFBRSxhQUFhLEVBQUUsTUFBTSxpQ0FBaUMsQ0FBQztBQUtoRTs7Ozs7R0FLRztBQUNILFNBQVMsV0FBVyxDQUFJLE9BQW1CLEVBQUUsRUFBVSxFQUFFLEtBQWE7SUFDcEUsT0FBTyxJQUFJLE9BQU8sQ0FBSSxDQUFDLE9BQU8sRUFBRSxNQUFNLEVBQUUsRUFBRTtRQUN4QyxNQUFNLEtBQUssR0FBRyxVQUFVLENBQUMsR0FBRyxFQUFFLENBQUMsTUFBTSxDQUFDLElBQUksS0FBSyxDQUFDLEdBQUcsS0FBSyxvQkFBb0IsRUFBRSxJQUFJLENBQUMsQ0FBQyxFQUFFLEVBQUUsQ0FBQyxDQUFDO1FBQzFGLEtBQUssQ0FBQyxLQUFLLEVBQUUsRUFBRSxDQUFDO1FBQ2hCLE9BQU8sQ0FBQyxJQUFJLENBQ1YsQ0FBQyxLQUFLLEVBQUUsRUFBRSxHQUFHLFlBQVksQ0FBQyxLQUFLLENBQUMsQ0FBQyxDQUFDLE9BQU8sQ0FBQyxLQUFLLENBQUMsQ0FBQyxDQUFDLENBQUMsRUFDbkQsQ0FBQyxHQUFHLEVBQUUsRUFBRSxHQUFHLFlBQVksQ0FBQyxLQUFLLENBQUMsQ0FBQyxDQUFDLE1BQU0sQ0FBQyxHQUFHLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FDL0MsQ0FBQztJQUNKLENBQUMsQ0FBQyxDQUFDO0FBQ0wsQ0FBQztBQUVEOzs7Ozs7R0FNRztBQUNILE1BQU0sQ0FBQyxLQUFLLFVBQVUsbUJBQW1CLENBQUMsU0FBUyxHQUFHLElBQUk7SUFDeEQsSUFBSSxDQUFDO1FBQ0gsTUFBTSxXQUFXLENBQUMsYUFBYSxFQUFFLENBQUMsY0FBYyxFQUFFLEVBQUUsU0FBUyxFQUFFLHlCQUF5QixDQUFDLENBQUM7UUFDMUYsT0FBTyxFQUFFLFFBQVEsRUFBRSxXQUFXLEVBQUUsQ0FBQztJQUNuQyxDQUFDO0lBQUMsTUFBTSxDQUFDO1FBQ1AsT0FBTyxFQUFFLFFBQVEsRUFBRSxjQUFjLEVBQUUsQ0FBQztJQUN0QyxDQUFDO0FBQ0gsQ0FBQztBQWVEOzs7Ozs7O0dBT0c7QUFDSCxNQUFNLFVBQVUsZ0JBQWdCLENBQUMsTUFBeUIsRUFBRSxTQUFTLEdBQUcsSUFBSTtJQUMxRSxPQUFPLEtBQUssSUFBSSxFQUFFO1FBQ2hCLElBQUksQ0FBQztZQUNILE1BQU0sTUFBTSxHQUFHLE9BQU8sTUFBTSxLQUFLLFVBQVUsQ0FBQyxDQUFDLENBQUMsTUFBTSxNQUFNLEVBQUUsQ0FBQyxDQUFDLENBQUMsTUFBTSxDQUFDO1lBQ3RFLE1BQU0sV0FBVyxDQUFDLE1BQU0sQ0FBQyxJQUFJLEVBQUUsRUFBRSxTQUFTLEVBQUUsWUFBWSxDQUFDLENBQUM7WUFDMUQsT0FBTyxFQUFFLEtBQUssRUFBRSxXQUFXLEVBQUUsQ0FBQztRQUNoQyxDQUFDO1FBQUMsTUFBTSxDQUFDO1lBQ1AsT0FBTyxFQUFFLEtBQUssRUFBRSxjQUFjLEVBQUUsQ0FBQztRQUNuQyxDQUFDO0lBQ0gsQ0FBQyxDQUFDO0FBQ0osQ0FBQztBQUVEOzs7Ozs7Ozs7Ozs7R0FZRztBQUNILE1BQU0sVUFBVSxtQkFBbUIsQ0FDakMsR0FBRyxNQUEwQztJQUU3QyxPQUFPLEtBQUssSUFBSSxFQUFFO1FBQ2hCLE1BQU0sT0FBTyxHQUFHLE1BQU0sT0FBTyxDQUFDLEdBQUcsQ0FBQyxNQUFNLENBQUMsR0FBRyxDQUFDLENBQUMsS0FBSyxFQUFFLEVBQUUsQ0FBQyxLQUFLLEVBQUUsQ0FBQyxLQUFLLENBQUMsR0FBRyxFQUFFLENBQUMsQ0FBQyxFQUFtQixDQUFBLENBQUMsQ0FBQyxDQUFDLENBQUM7UUFDcEcsT0FBTyxNQUFNLENBQUMsTUFBTSxDQUFDLEVBQUUsRUFBRSxHQUFHLE9BQU8sQ0FBaUIsQ0FBQztJQUN2RCxDQUFDLENBQUM7QUFDSixDQUFDO0FBV0Q7Ozs7Ozs7R0FPRztBQUNILE1BQU0sVUFBVSxnQkFBZ0IsQ0FBQyxVQUFrQztJQUNqRSxPQUFPLEtBQUssSUFBSSxFQUFFLENBQUMsQ0FBQztRQUNsQixPQUFPLEVBQUUsVUFBVSxDQUFDLFVBQVUsS0FBSyxDQUFDLENBQUMsQ0FBQyxDQUFDLFdBQVc7WUFDaEQsQ0FBQyxDQUFDLFVBQVUsQ0FBQyxVQUFVLEtBQUssQ0FBQyxDQUFDLENBQUMsQ0FBQyxTQUFTLENBQUMsaUNBQWlDO2dCQUN6RSxDQUFDLENBQUMsY0FBYztLQUNyQixDQUFDLENBQUM7QUFDTCxDQUFDIiwic291cmNlc0NvbnRlbnQiOlsiLy8gQ29weXJpZ2h0IDIwMjYgUGlwZWxpbmUgQnVpbGRlciBDb250cmlidXRvcnNcbi8vIFNQRFgtTGljZW5zZS1JZGVudGlmaWVyOiBBcGFjaGUtMi4wXG5cbmltcG9ydCB7IGdldENvbm5lY3Rpb24gfSBmcm9tICdAcGlwZWxpbmUtYnVpbGRlci9waXBlbGluZS1kYXRhJztcblxudHlwZSBIZWFsdGhTdGF0dXMgPSAnY29ubmVjdGVkJyB8ICdkaXNjb25uZWN0ZWQnIHwgJ3Vua25vd24nO1xudHlwZSBIZWFsdGhSZXN1bHQgPSBSZWNvcmQ8c3RyaW5nLCBIZWFsdGhTdGF0dXM+O1xuXG4vKipcbiAqIFJlamVjdCBpZiBgcHJvbWlzZWAgaGFzbid0IHNldHRsZWQgd2l0aGluIGBtc2AuIEEgaGVhbHRoL3JlYWRpbmVzcyBwcm9iZVxuICogbXVzdCBuZXZlciBoYW5nIHRoZSBgL3JlYWR5YCByZXNwb25zZSDigJQgYSBkb3duIGRhdGFzdG9yZSAob3IgYSBCdWxsTVFcbiAqIGlvcmVkaXMgY2xpZW50IHdpdGggYG1heFJldHJpZXNQZXJSZXF1ZXN0OiBudWxsYCArIGFuIG9mZmxpbmUgcXVldWUpIGNhblxuICogbGVhdmUgYSBgcGluZygpYC9xdWVyeSBwZW5kaW5nIGluZGVmaW5pdGVseSwgc28gZXZlcnkgcHJvYmUgaXMgdGltZS1ib3hlZC5cbiAqL1xuZnVuY3Rpb24gd2l0aFRpbWVvdXQ8VD4ocHJvbWlzZTogUHJvbWlzZTxUPiwgbXM6IG51bWJlciwgbGFiZWw6IHN0cmluZyk6IFByb21pc2U8VD4ge1xuICByZXR1cm4gbmV3IFByb21pc2U8VD4oKHJlc29sdmUsIHJlamVjdCkgPT4ge1xuICAgIGNvbnN0IHRpbWVyID0gc2V0VGltZW91dCgoKSA9PiByZWplY3QobmV3IEVycm9yKGAke2xhYmVsfSB0aW1lZCBvdXQgYWZ0ZXIgJHttc31tc2ApKSwgbXMpO1xuICAgIHRpbWVyLnVucmVmPy4oKTtcbiAgICBwcm9taXNlLnRoZW4oXG4gICAgICAodmFsdWUpID0+IHsgY2xlYXJUaW1lb3V0KHRpbWVyKTsgcmVzb2x2ZSh2YWx1ZSk7IH0sXG4gICAgICAoZXJyKSA9PiB7IGNsZWFyVGltZW91dCh0aW1lcik7IHJlamVjdChlcnIpOyB9LFxuICAgICk7XG4gIH0pO1xufVxuXG4vKipcbiAqIFBvc3RncmVzIGhlYWx0aCBwcm9iZTogcmV0dXJucyBgeyBwb3N0Z3JlczogJ2Nvbm5lY3RlZCcgfWAgd2hlbiB0aGUgcG9vbCdzXG4gKiBjb25uZWN0aW9uIHRlc3Qgc3VjY2VlZHMsIGB7IHBvc3RncmVzOiAnZGlzY29ubmVjdGVkJyB9YCBvbiBhbnkgZXJyb3Igb3JcbiAqIHRpbWVvdXQgKHNvIGAvcmVhZHlgIGNvcnJlY3RseSByZXBvcnRzIDUwMykuIFVzZSBhcyAocGFydCBvZikgdGhlXG4gKiBgY2hlY2tEZXBlbmRlbmNpZXNgIG9wdGlvbiBvZiBgY3JlYXRlQXBwKClgIGZvciBhbnkgc2VydmljZSBiYWNrZWQgYnkgdGhlXG4gKiBzaGFyZWQgcGlwZWxpbmUtZGF0YSBEcml6emxlIGNvbm5lY3Rpb24uXG4gKi9cbmV4cG9ydCBhc3luYyBmdW5jdGlvbiBwb3N0Z3Jlc0hlYWx0aENoZWNrKHRpbWVvdXRNcyA9IDMwMDApOiBQcm9taXNlPEhlYWx0aFJlc3VsdD4ge1xuICB0cnkge1xuICAgIGF3YWl0IHdpdGhUaW1lb3V0KGdldENvbm5lY3Rpb24oKS50ZXN0Q29ubmVjdGlvbigpLCB0aW1lb3V0TXMsICdwb3N0Z3JlcyB0ZXN0Q29ubmVjdGlvbicpO1xuICAgIHJldHVybiB7IHBvc3RncmVzOiAnY29ubmVjdGVkJyB9O1xuICB9IGNhdGNoIHtcbiAgICByZXR1cm4geyBwb3N0Z3JlczogJ2Rpc2Nvbm5lY3RlZCcgfTtcbiAgfVxufVxuXG4vKiogTWluaW1hbCBzaGFwZSBvZiBhbiBpb3JlZGlzIGNsaWVudCBuZWVkZWQgZm9yIGEgaGVhbHRoIFBJTkcuICovXG5pbnRlcmZhY2UgUmVkaXNMaWtlIHtcbiAgcGluZygpOiBQcm9taXNlPHN0cmluZz47XG59XG5cbi8qKlxuICogU291cmNlIG9mIGEgcmVkaXMgY2xpZW50OiBlaXRoZXIgdGhlIGNsaWVudCBpdHNlbGYsIG9yIGEgKHN5bmMvYXN5bmMpIGdldHRlci5cbiAqIFRoZSBnZXR0ZXIgZm9ybSBzdXBwb3J0cyBCdWxsTVEncyBgcXVldWUuY2xpZW50YCAoYSBgUHJvbWlzZTxSZWRpc0NsaWVudD5gKVxuICogc28gYSBzZXJ2aWNlIGNhbiBwcm9iZSB0aGUgZXhhY3QgY29ubmVjdGlvbiBpdHMgcXVldWUgdXNlcyB3aXRob3V0IG9wZW5pbmdcbiAqIGEgc2Vjb25kIG9uZS5cbiAqL1xudHlwZSBSZWRpc0NsaWVudFNvdXJjZSA9IFJlZGlzTGlrZSB8ICgoKSA9PiBSZWRpc0xpa2UgfCBQcm9taXNlPFJlZGlzTGlrZT4pO1xuXG4vKipcbiAqIFJlZGlzIGhlYWx0aCBwcm9iZSBmYWN0b3J5OiBwYXNzIHRoZSBzZXJ2aWNlJ3MgRVhJU1RJTkcgaW9yZWRpcyBjb25uZWN0aW9uXG4gKiAob3IgYSBnZXR0ZXIgZm9yIGl0IOKAlCBlLmcuIGAoKSA9PiBxdWV1ZS5jbGllbnRgKSBhbmQgZ2V0IGJhY2sgYVxuICogYGNoZWNrRGVwZW5kZW5jaWVzYCBjYWxsYmFjayB0aGF0IGBQSU5HYHMgaXQuIE1pcnJvcnMgYG1vbmdvSGVhbHRoQ2hlY2tgIOKAlFxuICogaXQgcHJvYmVzIHRoZSByZWFsIGNvbm5lY3Rpb24gcmF0aGVyIHRoYW4gb3BlbmluZyBhIHRocm93YXdheSBvbmUgcGVyIGNhbGwuXG4gKiBUaGUgUElORyBpcyB0aW1lLWJveGVkIHNvIGEgZG93biByZWRpcyAod2hvc2Ugb2ZmbGluZSBxdWV1ZSB3b3VsZCBvdGhlcndpc2VcbiAqIGJ1ZmZlciB0aGUgY29tbWFuZCkgcmVwb3J0cyBgZGlzY29ubmVjdGVkYCBxdWlja2x5IGluc3RlYWQgb2YgaGFuZ2luZy5cbiAqL1xuZXhwb3J0IGZ1bmN0aW9uIHJlZGlzSGVhbHRoQ2hlY2soc291cmNlOiBSZWRpc0NsaWVudFNvdXJjZSwgdGltZW91dE1zID0gMjAwMCk6ICgpID0+IFByb21pc2U8SGVhbHRoUmVzdWx0PiB7XG4gIHJldHVybiBhc3luYyAoKSA9PiB7XG4gICAgdHJ5IHtcbiAgICAgIGNvbnN0IGNsaWVudCA9IHR5cGVvZiBzb3VyY2UgPT09ICdmdW5jdGlvbicgPyBhd2FpdCBzb3VyY2UoKSA6IHNvdXJjZTtcbiAgICAgIGF3YWl0IHdpdGhUaW1lb3V0KGNsaWVudC5waW5nKCksIHRpbWVvdXRNcywgJ3JlZGlzIHBpbmcnKTtcbiAgICAgIHJldHVybiB7IHJlZGlzOiAnY29ubmVjdGVkJyB9O1xuICAgIH0gY2F0Y2gge1xuICAgICAgcmV0dXJuIHsgcmVkaXM6ICdkaXNjb25uZWN0ZWQnIH07XG4gICAgfVxuICB9O1xufVxuXG4vKipcbiAqIENvbWJpbmUgc2V2ZXJhbCBkZXBlbmRlbmN5IHByb2JlcyBpbnRvIG9uZSBgY2hlY2tEZXBlbmRlbmNpZXNgIGNhbGxiYWNrIHRoYXRcbiAqIHJ1bnMgdGhlbSBJTiBQQVJBTExFTCBhbmQgbWVyZ2VzIHRoZWlyIHJlc3VsdHMuIEEgc2xvdy9mYWlsZWQgcHJvYmUgY2FuJ3RcbiAqIGRlbGF5IHRoZSBvdGhlcnMgb3IgdGltZSBvdXQgdGhlIHdob2xlIGAvcmVhZHlgIHJlc3BvbnNlLlxuICpcbiAqIENPTlRSQUNUOiBlYWNoIHByb2JlIG11c3QgQ0FUQ0ggaW50ZXJuYWxseSBhbmQgUkVUVVJOIGl0cyBgeyBuYW1lOiBzdGF0dXMgfWBcbiAqIChhcyB0aGUgYnVpbHQtaW4gcG9zdGdyZXMvcmVkaXMvbW9uZ28gcHJvYmVzIGRvKS4gQSBwcm9iZSB0aGF0IFRIUk9XUyBpc1xuICogbWVyZ2VkIGFzIGB7fWAg4oCUIGl0cyBkZXBlbmRlbmN5IHRoZW4gdmFuaXNoZXMgZnJvbSB0aGUgbWFwIGFuZCBgL3JlYWR5YFxuICogcmVwb3J0cyAyMDAgd2l0aCB0aGUgZGVwIHNpbXBseSBhYnNlbnQgKGEgc2lsZW50IGZhbHNlLWhlYWx0aHkpLCByYXRoZXIgdGhhblxuICogc3VyZmFjaW5nIGl0IGFzIGBkaXNjb25uZWN0ZWRgLiBUaGUgYC5jYXRjaCgoKSA9PiAoe30pKWAgYmVsb3cgb25seSBleGlzdHMgc29cbiAqIG9uZSBtaXNiZWhhdmluZyBwcm9iZSBjYW4ndCB0YWtlIGRvd24gdGhlIG90aGVycywgTk9UIGFzIGEgc3Vic3RpdHV0ZSBmb3IgYVxuICogcHJvYmUgcmV0dXJuaW5nIGl0cyBvd24gZGlzY29ubmVjdGVkIHN0YXR1cy5cbiAqL1xuZXhwb3J0IGZ1bmN0aW9uIGNvbWJpbmVIZWFsdGhDaGVja3MoXG4gIC4uLmNoZWNrczogQXJyYXk8KCkgPT4gUHJvbWlzZTxIZWFsdGhSZXN1bHQ+PlxuKTogKCkgPT4gUHJvbWlzZTxIZWFsdGhSZXN1bHQ+IHtcbiAgcmV0dXJuIGFzeW5jICgpID0+IHtcbiAgICBjb25zdCByZXN1bHRzID0gYXdhaXQgUHJvbWlzZS5hbGwoY2hlY2tzLm1hcCgoY2hlY2spID0+IGNoZWNrKCkuY2F0Y2goKCkgPT4gKHt9IGFzIEhlYWx0aFJlc3VsdCkpKSk7XG4gICAgcmV0dXJuIE9iamVjdC5hc3NpZ24oe30sIC4uLnJlc3VsdHMpIGFzIEhlYWx0aFJlc3VsdDtcbiAgfTtcbn1cblxuLyoqXG4gKiBNaW5pbWFsIHNoYXBlIGV4cGVjdGVkIG9mIGEgbW9uZ29vc2UgY29ubmVjdGlvbiBmb3IgdGhlIGhlYWx0aCBwcm9iZS5cbiAqIEFjY2VwdHMgYG1vbmdvb3NlLmNvbm5lY3Rpb25gIGRpcmVjdGx5IHdpdGhvdXQgcmVxdWlyaW5nIG1vbmdvb3NlIGFzIGFcbiAqIGRlcGVuZGVuY3kgb2YgYXBpLXNlcnZlci5cbiAqL1xuaW50ZXJmYWNlIE1vbmdvb3NlQ29ubmVjdGlvbkxpa2Uge1xuICByZWFkeVN0YXRlOiBudW1iZXI7XG59XG5cbi8qKlxuICogTW9uZ29EQiBoZWFsdGggcHJvYmUgZmFjdG9yeTogcGFzcyBgbW9uZ29vc2UuY29ubmVjdGlvbmAgYW5kIGdldCBiYWNrIGFcbiAqIGBjaGVja0RlcGVuZGVuY2llc2AgY2FsbGJhY2sgdGhhdCByZXBvcnRzIGJhc2VkIG9uIG1vbmdvb3NlJ3MgcmVhZHlTdGF0ZS5cbiAqXG4gKiBNYXBwaW5nIChwZXIgbW9uZ29vc2UgZG9jcyk6IDE9Y29ubmVjdGVkLCAyPWNvbm5lY3RpbmcsIDM9ZGlzY29ubmVjdGluZyxcbiAqIGFueSBvdGhlciB2YWx1ZSAoMD1kaXNjb25uZWN0ZWQsIDk5PXVuaW5pdGlhbGl6ZWQpIGlzIHRyZWF0ZWQgYXNcbiAqICdkaXNjb25uZWN0ZWQnIHNvIGAvaGVhbHRoYCByZXR1cm5zIDUwMy5cbiAqL1xuZXhwb3J0IGZ1bmN0aW9uIG1vbmdvSGVhbHRoQ2hlY2soY29ubmVjdGlvbjogTW9uZ29vc2VDb25uZWN0aW9uTGlrZSk6ICgpID0+IFByb21pc2U8SGVhbHRoUmVzdWx0PiB7XG4gIHJldHVybiBhc3luYyAoKSA9PiAoe1xuICAgIG1vbmdvZGI6IGNvbm5lY3Rpb24ucmVhZHlTdGF0ZSA9PT0gMSA/ICdjb25uZWN0ZWQnXG4gICAgICA6IGNvbm5lY3Rpb24ucmVhZHlTdGF0ZSA9PT0gMiA/ICd1bmtub3duJyAvLyBjb25uZWN0aW5nOyB0cmVhdCBhcyB0cmFuc2llbnRcbiAgICAgICAgOiAnZGlzY29ubmVjdGVkJyxcbiAgfSk7XG59XG4iXX0=
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { Request, Response, NextFunction } from 'express';
|
|
2
|
+
interface CachedEntry {
|
|
3
|
+
statusCode: number;
|
|
4
|
+
body: unknown;
|
|
5
|
+
expiresAt: number;
|
|
6
|
+
/** Reservation placeholder: the request is in-flight, no real response yet.
|
|
7
|
+
* A duplicate that sees a pending entry is rejected with 409, not replayed. */
|
|
8
|
+
pending?: boolean;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Pluggable backend for idempotency-key replay cache. The default in-memory
|
|
12
|
+
* implementation works fine single-replica; a Redis-backed implementation
|
|
13
|
+
* is required for correct deduplication across horizontally-scaled replicas
|
|
14
|
+
* (otherwise two replicas behind a load-balancer cache independently and a
|
|
15
|
+
* retry hitting a different pod won't replay).
|
|
16
|
+
*/
|
|
17
|
+
export interface IdempotencyStore {
|
|
18
|
+
get(key: string): Promise<CachedEntry | null>;
|
|
19
|
+
set(key: string, entry: CachedEntry, ttlSeconds: number): Promise<void>;
|
|
20
|
+
/**
|
|
21
|
+
* Atomically claim `key` IFF it is currently absent. Returns `true` when THIS
|
|
22
|
+
* caller set the entry (won the race), `false` when an entry already exists.
|
|
23
|
+
* This is what serializes concurrent first-time requests that share an
|
|
24
|
+
* Idempotency-Key — a plain get-then-set leaves a window where two requests
|
|
25
|
+
* both see "not cached" and both execute the mutation.
|
|
26
|
+
*/
|
|
27
|
+
reserve(key: string, entry: CachedEntry, ttlSeconds: number): Promise<boolean>;
|
|
28
|
+
/** Remove a key — releases a reservation after a non-cacheable (non-2xx) response. */
|
|
29
|
+
delete(key: string): Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
/** Default in-memory store. Single-replica only. Exported for testing. */
|
|
32
|
+
export declare function createMemoryStore(): IdempotencyStore;
|
|
33
|
+
/**
|
|
34
|
+
* Minimal Redis surface the idempotency store needs (an ioredis subset).
|
|
35
|
+
* `set` supports the variadic option tail: `SET key val EX <ttl> [NX]`, which
|
|
36
|
+
* returns `'OK'` on success and `null` when an `NX` set was refused.
|
|
37
|
+
*/
|
|
38
|
+
export interface RedisIdempotencyClient {
|
|
39
|
+
get(key: string): Promise<string | null>;
|
|
40
|
+
set(key: string, value: string, ...args: (string | number)[]): Promise<string | null>;
|
|
41
|
+
del(key: string): Promise<number>;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Redis-backed {@link IdempotencyStore} for correct deduplication across
|
|
45
|
+
* horizontally-scaled replicas. `reserve` maps to an atomic `SET … NX` so two
|
|
46
|
+
* pods behind a load-balancer can't both win a first-time request sharing an
|
|
47
|
+
* Idempotency-Key; a retry landing on ANY pod sees the same committed response.
|
|
48
|
+
* Redis TTL owns expiry (the `CachedEntry.expiresAt` field is carried only for
|
|
49
|
+
* API-shape parity with the memory store).
|
|
50
|
+
*/
|
|
51
|
+
export declare function createRedisIdempotencyStore(redis: RedisIdempotencyClient): IdempotencyStore;
|
|
52
|
+
/**
|
|
53
|
+
* Build a Redis-backed idempotency store from the shared env Redis (the same
|
|
54
|
+
* `REDIS_URL` / `REDIS_HOST` wiring the rate-limiter and audit-spool use).
|
|
55
|
+
* Returns `null` when Redis isn't configured/available so the caller keeps the
|
|
56
|
+
* in-memory default (single-replica correctness). Never throws.
|
|
57
|
+
*/
|
|
58
|
+
export declare function createEnvRedisIdempotencyStore(): IdempotencyStore | null;
|
|
59
|
+
/** Inject the process-wide idempotency store (e.g. a Redis-backed one). */
|
|
60
|
+
export declare function setIdempotencyStore(store: IdempotencyStore): void;
|
|
61
|
+
/** The current process-wide default store (in-memory unless overridden). */
|
|
62
|
+
export declare function getIdempotencyStore(): IdempotencyStore;
|
|
63
|
+
export interface IdempotencyMiddlewareOptions {
|
|
64
|
+
/** Custom store backend. Defaults to the process-wide store set via
|
|
65
|
+
* {@link setIdempotencyStore} (in-memory until `createApp` wires Redis). */
|
|
66
|
+
store?: IdempotencyStore;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Middleware that supports idempotency keys for POST/PUT/DELETE mutations.
|
|
70
|
+
*
|
|
71
|
+
* When a request includes the `Idempotency-Key` header:
|
|
72
|
+
* - First call: processes normally, caches the response
|
|
73
|
+
* - Subsequent calls with same key: returns cached response (prevents duplicate mutations)
|
|
74
|
+
*
|
|
75
|
+
* Defaults to an in-memory store (single-replica). Pass a custom
|
|
76
|
+
* cross-replica `{ store }` implementing `IdempotencyStore` to dedupe across
|
|
77
|
+
* replicas.
|
|
78
|
+
*/
|
|
79
|
+
export declare function idempotencyMiddleware(options?: IdempotencyMiddlewareOptions): (req: Request, res: Response, next: NextFunction) => void;
|
|
80
|
+
export {};
|