@rytass/bpm-core-nestjs-module 0.13.2 → 0.13.4
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/CHANGELOG.md +53 -0
- package/package.json +13 -6
- package/src/index.d.ts +1 -0
- package/src/index.js +1 -0
- package/src/index.js.map +1 -1
- package/src/lib/attachment/attachment-storage.provider.d.ts +11 -0
- package/src/lib/attachment/attachment-storage.provider.js +15 -1
- package/src/lib/attachment/attachment-storage.provider.js.map +1 -1
- package/src/lib/attachment/attachment.module.js +16 -1
- package/src/lib/attachment/attachment.module.js.map +1 -1
- package/src/lib/bpm/bpm-root-options.d.ts +134 -0
- package/src/lib/bpm/bpm-root-options.js +61 -0
- package/src/lib/bpm/bpm-root-options.js.map +1 -0
- package/src/lib/bpm/bpm-root.module.d.ts +101 -137
- package/src/lib/bpm/bpm-root.module.js +83 -118
- package/src/lib/bpm/bpm-root.module.js.map +1 -1
- package/src/lib/bpm/index.d.ts +1 -0
- package/src/lib/bpm/index.js +1 -0
- package/src/lib/bpm/index.js.map +1 -1
- package/src/lib/calendar/business-calendar.provider.d.ts +8 -4
- package/src/lib/calendar/business-calendar.provider.js +15 -6
- package/src/lib/calendar/business-calendar.provider.js.map +1 -1
- package/src/lib/common/outbox.d.ts +26 -0
- package/src/lib/common/outbox.js +64 -0
- package/src/lib/common/outbox.js.map +1 -0
- package/src/lib/delegation/delegation.service.js +7 -7
- package/src/lib/delegation/delegation.service.js.map +1 -1
- package/src/lib/form-data-source/form-data-source.module.js +2 -4
- package/src/lib/form-data-source/form-data-source.module.js.map +1 -1
- package/src/lib/form-data-source/form-data-source.provider.d.ts +14 -0
- package/src/lib/form-data-source/form-data-source.provider.js +23 -0
- package/src/lib/form-data-source/form-data-source.provider.js.map +1 -0
- package/src/lib/form-data-source/index.d.ts +1 -0
- package/src/lib/form-data-source/index.js +1 -0
- package/src/lib/form-data-source/index.js.map +1 -1
- package/src/lib/identity/default-member-resolver.d.ts +39 -0
- package/src/lib/identity/default-member-resolver.js +81 -0
- package/src/lib/identity/default-member-resolver.js.map +1 -0
- package/src/lib/identity/identity.module.d.ts +12 -2
- package/src/lib/identity/identity.module.js +2 -1
- package/src/lib/identity/identity.module.js.map +1 -1
- package/src/lib/identity/index.d.ts +1 -0
- package/src/lib/identity/index.js +1 -0
- package/src/lib/identity/index.js.map +1 -1
- package/src/lib/migrations/0000000023000-workflow-webhook-deliveries.d.ts +17 -0
- package/src/lib/migrations/0000000023000-workflow-webhook-deliveries.js +61 -0
- package/src/lib/migrations/0000000023000-workflow-webhook-deliveries.js.map +1 -0
- package/src/lib/migrations/0000000024000-workflow-webhook-endpoints.d.ts +16 -0
- package/src/lib/migrations/0000000024000-workflow-webhook-endpoints.js +71 -0
- package/src/lib/migrations/0000000024000-workflow-webhook-endpoints.js.map +1 -0
- package/src/lib/migrations/index.d.ts +2 -0
- package/src/lib/migrations/index.js +6 -0
- package/src/lib/migrations/index.js.map +1 -1
- package/src/lib/notification/notification-delivery.service.js +4 -57
- package/src/lib/notification/notification-delivery.service.js.map +1 -1
- package/src/lib/notification/notification.service.js +4 -3
- package/src/lib/notification/notification.service.js.map +1 -1
- package/src/lib/template/template.service.d.ts +9 -0
- package/src/lib/template/template.service.js +31 -0
- package/src/lib/template/template.service.js.map +1 -1
- package/src/lib/template/workflow-definition.validator.js +54 -1
- package/src/lib/template/workflow-definition.validator.js.map +1 -1
- package/src/lib/workflow-engine/workflow-engine.enums.d.ts +3 -1
- package/src/lib/workflow-engine/workflow-engine.enums.js +2 -0
- package/src/lib/workflow-engine/workflow-engine.enums.js.map +1 -1
- package/src/lib/workflow-engine/workflow-engine.module.js +3 -0
- package/src/lib/workflow-engine/workflow-engine.module.js.map +1 -1
- package/src/lib/workflow-engine/workflow-engine.service.d.ts +5 -2
- package/src/lib/workflow-engine/workflow-engine.service.js +142 -26
- package/src/lib/workflow-engine/workflow-engine.service.js.map +1 -1
- package/src/lib/workflow-webhook/index.d.ts +24 -0
- package/src/lib/workflow-webhook/index.js +28 -0
- package/src/lib/workflow-webhook/index.js.map +1 -0
- package/src/lib/workflow-webhook/workflow-webhook-allowlist.d.ts +38 -0
- package/src/lib/workflow-webhook/workflow-webhook-allowlist.js +296 -0
- package/src/lib/workflow-webhook/workflow-webhook-allowlist.js.map +1 -0
- package/src/lib/workflow-webhook/workflow-webhook-database-source.d.ts +33 -0
- package/src/lib/workflow-webhook/workflow-webhook-database-source.js +129 -0
- package/src/lib/workflow-webhook/workflow-webhook-database-source.js.map +1 -0
- package/src/lib/workflow-webhook/workflow-webhook-delivery-scheduler.service.d.ts +25 -0
- package/src/lib/workflow-webhook/workflow-webhook-delivery-scheduler.service.js +92 -0
- package/src/lib/workflow-webhook/workflow-webhook-delivery-scheduler.service.js.map +1 -0
- package/src/lib/workflow-webhook/workflow-webhook-delivery.entity.d.ts +53 -0
- package/src/lib/workflow-webhook/workflow-webhook-delivery.entity.js +97 -0
- package/src/lib/workflow-webhook/workflow-webhook-delivery.entity.js.map +1 -0
- package/src/lib/workflow-webhook/workflow-webhook-delivery.enums.d.ts +28 -0
- package/src/lib/workflow-webhook/workflow-webhook-delivery.enums.js +36 -0
- package/src/lib/workflow-webhook/workflow-webhook-delivery.enums.js.map +1 -0
- package/src/lib/workflow-webhook/workflow-webhook-delivery.object.d.ts +28 -0
- package/src/lib/workflow-webhook/workflow-webhook-delivery.object.js +88 -0
- package/src/lib/workflow-webhook/workflow-webhook-delivery.object.js.map +1 -0
- package/src/lib/workflow-webhook/workflow-webhook-delivery.resolver.d.ts +9 -0
- package/src/lib/workflow-webhook/workflow-webhook-delivery.resolver.js +66 -0
- package/src/lib/workflow-webhook/workflow-webhook-delivery.resolver.js.map +1 -0
- package/src/lib/workflow-webhook/workflow-webhook-delivery.service.d.ts +116 -0
- package/src/lib/workflow-webhook/workflow-webhook-delivery.service.js +705 -0
- package/src/lib/workflow-webhook/workflow-webhook-delivery.service.js.map +1 -0
- package/src/lib/workflow-webhook/workflow-webhook-delivery.subscriber.d.ts +23 -0
- package/src/lib/workflow-webhook/workflow-webhook-delivery.subscriber.js +85 -0
- package/src/lib/workflow-webhook/workflow-webhook-delivery.subscriber.js.map +1 -0
- package/src/lib/workflow-webhook/workflow-webhook-endpoint-admin.dto.d.ts +85 -0
- package/src/lib/workflow-webhook/workflow-webhook-endpoint-admin.dto.js +345 -0
- package/src/lib/workflow-webhook/workflow-webhook-endpoint-admin.dto.js.map +1 -0
- package/src/lib/workflow-webhook/workflow-webhook-endpoint-admin.resolver.d.ts +23 -0
- package/src/lib/workflow-webhook/workflow-webhook-endpoint-admin.resolver.js +159 -0
- package/src/lib/workflow-webhook/workflow-webhook-endpoint-admin.resolver.js.map +1 -0
- package/src/lib/workflow-webhook/workflow-webhook-endpoint-admin.service.d.ts +93 -0
- package/src/lib/workflow-webhook/workflow-webhook-endpoint-admin.service.js +464 -0
- package/src/lib/workflow-webhook/workflow-webhook-endpoint-admin.service.js.map +1 -0
- package/src/lib/workflow-webhook/workflow-webhook-endpoint.entity.d.ts +52 -0
- package/src/lib/workflow-webhook/workflow-webhook-endpoint.entity.js +135 -0
- package/src/lib/workflow-webhook/workflow-webhook-endpoint.entity.js.map +1 -0
- package/src/lib/workflow-webhook/workflow-webhook-enqueue.d.ts +46 -0
- package/src/lib/workflow-webhook/workflow-webhook-enqueue.js +134 -0
- package/src/lib/workflow-webhook/workflow-webhook-enqueue.js.map +1 -0
- package/src/lib/workflow-webhook/workflow-webhook-options.d.ts +86 -0
- package/src/lib/workflow-webhook/workflow-webhook-options.js +88 -0
- package/src/lib/workflow-webhook/workflow-webhook-options.js.map +1 -0
- package/src/lib/workflow-webhook/workflow-webhook-options.module.d.ts +18 -0
- package/src/lib/workflow-webhook/workflow-webhook-options.module.js +57 -0
- package/src/lib/workflow-webhook/workflow-webhook-options.module.js.map +1 -0
- package/src/lib/workflow-webhook/workflow-webhook-secret-cipher.d.ts +20 -0
- package/src/lib/workflow-webhook/workflow-webhook-secret-cipher.js +66 -0
- package/src/lib/workflow-webhook/workflow-webhook-secret-cipher.js.map +1 -0
- package/src/lib/workflow-webhook/workflow-webhook.errors.d.ts +35 -0
- package/src/lib/workflow-webhook/workflow-webhook.errors.js +41 -0
- package/src/lib/workflow-webhook/workflow-webhook.errors.js.map +1 -0
- package/src/lib/workflow-webhook/workflow-webhook.module.d.ts +9 -0
- package/src/lib/workflow-webhook/workflow-webhook.module.js +62 -0
- package/src/lib/workflow-webhook/workflow-webhook.module.js.map +1 -0
- package/src/lib/workflow-webhook/workflow-webhook.provider.d.ts +12 -0
- package/src/lib/workflow-webhook/workflow-webhook.provider.js +21 -0
- package/src/lib/workflow-webhook/workflow-webhook.provider.js.map +1 -0
- package/src/lib/workflow-webhook/workflow-webhook.queries.d.ts +38 -0
- package/src/lib/workflow-webhook/workflow-webhook.queries.js +142 -0
- package/src/lib/workflow-webhook/workflow-webhook.queries.js.map +1 -0
- package/src/lib/workflow-webhook/workflow-webhook.service.d.ts +50 -0
- package/src/lib/workflow-webhook/workflow-webhook.service.js +221 -0
- package/src/lib/workflow-webhook/workflow-webhook.service.js.map +1 -0
- package/src/lib/workflow-webhook/workflow-webhook.types.d.ts +127 -0
- package/src/lib/workflow-webhook/workflow-webhook.types.js +33 -0
- package/src/lib/workflow-webhook/workflow-webhook.types.js.map +1 -0
- package/src/lib/workflow-webhook/workflow-webhook.validator.d.ts +20 -0
- package/src/lib/workflow-webhook/workflow-webhook.validator.js +97 -0
- package/src/lib/workflow-webhook/workflow-webhook.validator.js.map +1 -0
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var WorkflowWebhookDeliveryService_1;
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.WorkflowWebhookDeliveryService = exports.BPM_WORKFLOW_WEBHOOK_FETCH = void 0;
|
|
5
|
+
const tslib_1 = require("tslib");
|
|
6
|
+
const node_crypto_1 = require("node:crypto");
|
|
7
|
+
const common_1 = require("@nestjs/common");
|
|
8
|
+
const typeorm_1 = require("@nestjs/typeorm");
|
|
9
|
+
const typeorm_2 = require("typeorm");
|
|
10
|
+
const outbox_1 = require("../common/outbox");
|
|
11
|
+
const activity_log_entity_1 = require("../workflow-engine/activity-log.entity");
|
|
12
|
+
const workflow_engine_enums_1 = require("../workflow-engine/workflow-engine.enums");
|
|
13
|
+
const workflow_graph_1 = require("@rytass/bpm-core-shared/workflow-graph");
|
|
14
|
+
const workflow_webhook_allowlist_1 = require("./workflow-webhook-allowlist");
|
|
15
|
+
const workflow_webhook_delivery_enums_1 = require("./workflow-webhook-delivery.enums");
|
|
16
|
+
const workflow_webhook_delivery_entity_1 = require("./workflow-webhook-delivery.entity");
|
|
17
|
+
const workflow_webhook_enqueue_1 = require("./workflow-webhook-enqueue");
|
|
18
|
+
const workflow_webhook_options_1 = require("./workflow-webhook-options");
|
|
19
|
+
const workflow_webhook_service_1 = require("./workflow-webhook.service");
|
|
20
|
+
/**
|
|
21
|
+
* The HTTP client deliveries go through. Defaults to the global `fetch`;
|
|
22
|
+
* bound mainly so tests can observe requests without a network.
|
|
23
|
+
*/
|
|
24
|
+
exports.BPM_WORKFLOW_WEBHOOK_FETCH = Symbol('BPM_WORKFLOW_WEBHOOK_FETCH');
|
|
25
|
+
/** How much of a failing response body an administrator gets to see. */
|
|
26
|
+
const MAX_ERROR_DETAIL_LENGTH = 500;
|
|
27
|
+
/** Bytes read from a failing response before the rest is discarded. */
|
|
28
|
+
const MAX_ERROR_BODY_BYTES = 4_096;
|
|
29
|
+
const ALLOWED_METHODS = ['PATCH', 'POST', 'PUT'];
|
|
30
|
+
/** Deliveries from one claimed batch that are attempted at the same time. */
|
|
31
|
+
const MAX_CONCURRENT_ATTEMPTS = 5;
|
|
32
|
+
/**
|
|
33
|
+
* A claimed row whose attempt never wrote back is reclaimable after this. Rows
|
|
34
|
+
* are claimed no more than {@link MAX_CONCURRENT_ATTEMPTS} at a time and all of
|
|
35
|
+
* them start at once, so no claimed row ever waits in a queue: the window only
|
|
36
|
+
* has to cover one `buildRequest()` plus one request (30 s each at most) and
|
|
37
|
+
* the write.
|
|
38
|
+
*/
|
|
39
|
+
const STALE_CLAIM_MS = workflow_webhook_options_1.WORKFLOW_WEBHOOK_MAX_TIMEOUT_MS * 3;
|
|
40
|
+
let WorkflowWebhookDeliveryService = WorkflowWebhookDeliveryService_1 = class WorkflowWebhookDeliveryService {
|
|
41
|
+
constructor(deliveryRepository, activityLogRepository, webhookService, options = workflow_webhook_options_1.DEFAULT_BPM_WORKFLOW_WEBHOOK_OPTIONS, fetchImpl) {
|
|
42
|
+
this.deliveryRepository = deliveryRepository;
|
|
43
|
+
this.activityLogRepository = activityLogRepository;
|
|
44
|
+
this.webhookService = webhookService;
|
|
45
|
+
this.options = options;
|
|
46
|
+
this.logger = new common_1.Logger(WorkflowWebhookDeliveryService_1.name);
|
|
47
|
+
this.fetchImpl =
|
|
48
|
+
fetchImpl ??
|
|
49
|
+
((input, init) => fetch(input, init));
|
|
50
|
+
}
|
|
51
|
+
/** The clock every attempt reads; separate so tests can pin it. */
|
|
52
|
+
readCurrentTime() {
|
|
53
|
+
return new Date();
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Queues a NOTIFY node's webhooks inside the engine transaction `manager`.
|
|
57
|
+
* Sends nothing: delivery starts only once that transaction commits
|
|
58
|
+
* (ADR 18 §3.5). Rows that can already be judged undeliverable are written
|
|
59
|
+
* as `FAILED` together with their terminal activity log.
|
|
60
|
+
*/
|
|
61
|
+
async enqueueNotifyWebhooks(manager, context, targets) {
|
|
62
|
+
const listIssues = (0, workflow_graph_1.readNotifyWebhookStructureIssues)(targets).filter((issue) => issue.targetIndex === null);
|
|
63
|
+
// Publish refuses these, so reaching one means the snapshot was written
|
|
64
|
+
// around the lint. Nothing can be queued, but it must not pass silently.
|
|
65
|
+
if (listIssues.length) {
|
|
66
|
+
this.logger.warn(`Instance ${context.instance.id} node ${context.node.id}: webhooks not queued (${listIssues
|
|
67
|
+
.map((issue) => issue.code)
|
|
68
|
+
.join(', ')})`);
|
|
69
|
+
}
|
|
70
|
+
const drafts = await (0, workflow_webhook_enqueue_1.buildWorkflowWebhookDeliveryDrafts)({
|
|
71
|
+
context,
|
|
72
|
+
resolveEndpoint: (key, version) => this.webhookService.getEndpoint(key, version, manager),
|
|
73
|
+
targets,
|
|
74
|
+
});
|
|
75
|
+
if (!drafts.length) {
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
const repository = manager.getRepository(workflow_webhook_delivery_entity_1.WorkflowWebhookDeliveryEntity);
|
|
79
|
+
const saved = await repository.save(drafts.map((draft) => repository.create({ ...draft, attemptCount: 0 })));
|
|
80
|
+
const failed = saved.filter((row) => row.status === workflow_webhook_delivery_enums_1.WorkflowWebhookDeliveryStatusEnum.FAILED);
|
|
81
|
+
if (failed.length) {
|
|
82
|
+
const activityRepository = manager.getRepository(activity_log_entity_1.ActivityLogEntity);
|
|
83
|
+
const activities = await Promise.all(failed.map(async (row) => activityRepository.create(this.createTerminalActivity(row, await this.readEndpointLabel(row, manager)))));
|
|
84
|
+
await activityRepository.save(activities);
|
|
85
|
+
}
|
|
86
|
+
return saved.map((row) => row.id);
|
|
87
|
+
}
|
|
88
|
+
/** Every delivery queued for an instance, oldest first (ADR 18 §3.10). */
|
|
89
|
+
async listInstanceDeliveries(instanceId) {
|
|
90
|
+
return this.deliveryRepository.find({
|
|
91
|
+
order: { createdAt: 'ASC' },
|
|
92
|
+
where: { instanceId },
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Puts a `FAILED` delivery back in the queue with a fresh attempt budget
|
|
97
|
+
* and starts it at once. The delivery id does not change, so a receiver
|
|
98
|
+
* that already processed an earlier attempt still recognizes it.
|
|
99
|
+
*
|
|
100
|
+
* Only `FAILED` rows that were actually attempted qualify. A row in flight
|
|
101
|
+
* or already sent would be sent again; a row that failed while being queued
|
|
102
|
+
* (endpoint missing, lookup failed, parameter invalid) froze an event that
|
|
103
|
+
* never passed the parameter checks, and sending it would break the
|
|
104
|
+
* endpoint's contract. The state change and the audit entry commit together.
|
|
105
|
+
*/
|
|
106
|
+
async retryFailedDelivery(id, actorMemberId) {
|
|
107
|
+
const existing = await this.deliveryRepository.findOne({ where: { id } });
|
|
108
|
+
if (!existing) {
|
|
109
|
+
throw new common_1.NotFoundException(`Webhook delivery ${id} was not found`);
|
|
110
|
+
}
|
|
111
|
+
// Read before the transaction: a host lookup has no business holding the
|
|
112
|
+
// row lock or a second pooled connection.
|
|
113
|
+
const endpointLabel = await this.readEndpointLabel(existing);
|
|
114
|
+
const retried = await this.deliveryRepository.manager.transaction(async (manager) => {
|
|
115
|
+
const repository = manager.getRepository(workflow_webhook_delivery_entity_1.WorkflowWebhookDeliveryEntity);
|
|
116
|
+
const result = await repository.update({
|
|
117
|
+
attemptCount: (0, typeorm_2.MoreThan)(0),
|
|
118
|
+
id,
|
|
119
|
+
status: workflow_webhook_delivery_enums_1.WorkflowWebhookDeliveryStatusEnum.FAILED,
|
|
120
|
+
}, {
|
|
121
|
+
attemptCount: 0,
|
|
122
|
+
nextRetryAt: null,
|
|
123
|
+
status: workflow_webhook_delivery_enums_1.WorkflowWebhookDeliveryStatusEnum.PENDING,
|
|
124
|
+
});
|
|
125
|
+
const row = await repository.findOne({ where: { id } });
|
|
126
|
+
if (!row) {
|
|
127
|
+
throw new common_1.NotFoundException(`Webhook delivery ${id} was not found`);
|
|
128
|
+
}
|
|
129
|
+
if (!result.affected) {
|
|
130
|
+
throw new common_1.BadRequestException(row.status === workflow_webhook_delivery_enums_1.WorkflowWebhookDeliveryStatusEnum.FAILED
|
|
131
|
+
? `Webhook delivery ${id} failed before it was ever sent (${row.lastErrorCode ?? 'unknown'}) and cannot be retried`
|
|
132
|
+
: `Webhook delivery ${id} is ${row.status}; only FAILED deliveries can be retried`);
|
|
133
|
+
}
|
|
134
|
+
const activityRepository = manager.getRepository(activity_log_entity_1.ActivityLogEntity);
|
|
135
|
+
await activityRepository.save(activityRepository.create({
|
|
136
|
+
actorMemberId,
|
|
137
|
+
eventType: workflow_engine_enums_1.ActivityLogEventTypeEnum.WEBHOOK_DELIVERY_RETRIED,
|
|
138
|
+
instanceId: row.instanceId,
|
|
139
|
+
nodeId: row.nodeId,
|
|
140
|
+
payload: {
|
|
141
|
+
action: 'NOTIFY_WEBHOOK',
|
|
142
|
+
deliveryId: row.id,
|
|
143
|
+
endpointKey: row.endpointKey,
|
|
144
|
+
endpointLabel,
|
|
145
|
+
endpointVersion: row.endpointVersion,
|
|
146
|
+
previousErrorCode: row.lastErrorCode,
|
|
147
|
+
targetId: row.targetId,
|
|
148
|
+
},
|
|
149
|
+
taskId: null,
|
|
150
|
+
}));
|
|
151
|
+
return row;
|
|
152
|
+
});
|
|
153
|
+
// After the commit, like the subscriber's kick: the scheduler would get
|
|
154
|
+
// there too, but an administrator who pressed retry expects an answer now.
|
|
155
|
+
setImmediate(() => {
|
|
156
|
+
this.deliverByIds([retried.id]).catch((error) => {
|
|
157
|
+
this.logger.warn(`Retried webhook delivery ${retried.id} could not start immediately (${readErrorName(error)})`);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
return retried;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* One scheduler pass: claim what is due, a few rows at a time, until the
|
|
164
|
+
* batch size is reached or nothing more is due.
|
|
165
|
+
*
|
|
166
|
+
* Claiming the whole batch up front and attempting it a few at a time left
|
|
167
|
+
* the rows at the back holding a claim while they waited; past the stale
|
|
168
|
+
* window another worker took them over. Claiming only what starts at once
|
|
169
|
+
* means a claimed row is always a row in flight.
|
|
170
|
+
*/
|
|
171
|
+
async deliverDue(now) {
|
|
172
|
+
const { batchSize } = this.options.delivery;
|
|
173
|
+
let total = 0;
|
|
174
|
+
while (total < batchSize) {
|
|
175
|
+
const claimed = await this.claim({
|
|
176
|
+
ids: null,
|
|
177
|
+
limit: Math.min(MAX_CONCURRENT_ATTEMPTS, batchSize - total),
|
|
178
|
+
now: total === 0 && now ? now : this.readCurrentTime(),
|
|
179
|
+
});
|
|
180
|
+
if (!claimed.length) {
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
await this.attemptTogether(claimed);
|
|
184
|
+
total += claimed.length;
|
|
185
|
+
}
|
|
186
|
+
return total;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Attempts specific rows right after the transaction that queued them
|
|
190
|
+
* committed, in groups that start together. Rows another worker already
|
|
191
|
+
* claimed, or that are not due, are skipped by the claim itself.
|
|
192
|
+
*/
|
|
193
|
+
async deliverByIds(ids, now) {
|
|
194
|
+
const unique = [...new Set(ids)];
|
|
195
|
+
let total = 0;
|
|
196
|
+
for (let start = 0; start < unique.length; start += MAX_CONCURRENT_ATTEMPTS) {
|
|
197
|
+
const group = unique.slice(start, start + MAX_CONCURRENT_ATTEMPTS);
|
|
198
|
+
const claimed = await this.claim({
|
|
199
|
+
ids: group,
|
|
200
|
+
limit: group.length,
|
|
201
|
+
now: start === 0 && now ? now : this.readCurrentTime(),
|
|
202
|
+
});
|
|
203
|
+
await this.attemptTogether(claimed);
|
|
204
|
+
total += claimed.length;
|
|
205
|
+
}
|
|
206
|
+
return total;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Starts every claimed row at once (never more than
|
|
210
|
+
* {@link MAX_CONCURRENT_ATTEMPTS}). Strictly one by one, a single receiver
|
|
211
|
+
* that runs into its timeout held every other delivery back by that long
|
|
212
|
+
* (seen on the wrapper host: a 10 s timeout delayed an unrelated endpoint by
|
|
213
|
+
* 10 s). Each row still re-stamps and writes back under its own claim.
|
|
214
|
+
*/
|
|
215
|
+
async attemptTogether(rows) {
|
|
216
|
+
await Promise.all(rows.map((row) => this.attemptSafely(row)));
|
|
217
|
+
}
|
|
218
|
+
async attemptSafely(row) {
|
|
219
|
+
try {
|
|
220
|
+
await this.attempt(row);
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
// Only a failed write can land here. The row stays claimed and the
|
|
224
|
+
// stale window hands it to a later scan. The error's name only: a
|
|
225
|
+
// driver message can quote the parameters it was given.
|
|
226
|
+
this.logger.error(`Webhook delivery ${row.id} could not record its outcome (${readErrorName(error)})`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
async claim({ ids, limit, now, }) {
|
|
230
|
+
const staleBefore = new Date(now.getTime() - STALE_CLAIM_MS);
|
|
231
|
+
return this.deliveryRepository.manager.transaction(async (manager) => {
|
|
232
|
+
const claimedRows = (await manager.query(`
|
|
233
|
+
UPDATE workflow_webhook_deliveries
|
|
234
|
+
SET status = $1,
|
|
235
|
+
last_attempt_at = $2,
|
|
236
|
+
updated_at = $2
|
|
237
|
+
WHERE id IN (
|
|
238
|
+
SELECT id
|
|
239
|
+
FROM workflow_webhook_deliveries
|
|
240
|
+
WHERE (
|
|
241
|
+
(status = $3 AND (next_retry_at IS NULL OR next_retry_at <= $2))
|
|
242
|
+
OR (status = $1 AND last_attempt_at <= $4)
|
|
243
|
+
)
|
|
244
|
+
AND ($6::uuid[] IS NULL OR id = ANY($6::uuid[]))
|
|
245
|
+
ORDER BY created_at ASC
|
|
246
|
+
FOR UPDATE SKIP LOCKED
|
|
247
|
+
LIMIT $5
|
|
248
|
+
)
|
|
249
|
+
RETURNING id
|
|
250
|
+
`, [
|
|
251
|
+
workflow_webhook_delivery_enums_1.WorkflowWebhookDeliveryStatusEnum.DELIVERY_IN_PROGRESS,
|
|
252
|
+
now,
|
|
253
|
+
workflow_webhook_delivery_enums_1.WorkflowWebhookDeliveryStatusEnum.PENDING,
|
|
254
|
+
staleBefore,
|
|
255
|
+
limit,
|
|
256
|
+
ids ? [...ids] : null,
|
|
257
|
+
]));
|
|
258
|
+
const claimedIds = (0, outbox_1.readClaimedIds)(claimedRows);
|
|
259
|
+
return claimedIds.length
|
|
260
|
+
? manager
|
|
261
|
+
.getRepository(workflow_webhook_delivery_entity_1.WorkflowWebhookDeliveryEntity)
|
|
262
|
+
.find({ where: { id: (0, typeorm_2.In)([...claimedIds]) } })
|
|
263
|
+
: [];
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
async attempt(row) {
|
|
267
|
+
const startedAt = this.readCurrentTime();
|
|
268
|
+
// Re-stamping at the start of the attempt confirms this worker still owns
|
|
269
|
+
// the row: if another one reclaimed it between the claim and this line,
|
|
270
|
+
// the stamp no longer matches and this attempt stands down instead of
|
|
271
|
+
// sending a second time.
|
|
272
|
+
if (!(await this.moveClaim(row, row.lastAttemptAt, startedAt))) {
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
const claimed = {
|
|
276
|
+
...row,
|
|
277
|
+
lastAttemptAt: startedAt,
|
|
278
|
+
};
|
|
279
|
+
const attempt = row.attemptCount + 1;
|
|
280
|
+
const outcome = await this.resolveOutcome(claimed, attempt).catch((error) => ({
|
|
281
|
+
code: workflow_webhook_delivery_enums_1.WORKFLOW_WEBHOOK_DELIVERY_ERROR_CODES.INTERNAL_ERROR,
|
|
282
|
+
detail: readErrorName(error),
|
|
283
|
+
kind: 'RETRY',
|
|
284
|
+
status: null,
|
|
285
|
+
}));
|
|
286
|
+
await this.record(claimed, attempt, outcome, this.readCurrentTime());
|
|
287
|
+
}
|
|
288
|
+
async resolveOutcome(row, attempt) {
|
|
289
|
+
const lookup = await this.webhookService
|
|
290
|
+
.getEndpoint(row.endpointKey, row.endpointVersion)
|
|
291
|
+
.then((entry) => ({ entry }), (error) => ({ error }));
|
|
292
|
+
if ('error' in lookup) {
|
|
293
|
+
return {
|
|
294
|
+
code: workflow_webhook_delivery_enums_1.WORKFLOW_WEBHOOK_DELIVERY_ERROR_CODES.ENDPOINT_LOOKUP_FAILED,
|
|
295
|
+
detail: readErrorName(lookup.error),
|
|
296
|
+
kind: 'RETRY',
|
|
297
|
+
status: null,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
if (!lookup.entry) {
|
|
301
|
+
return {
|
|
302
|
+
code: workflow_webhook_delivery_enums_1.WORKFLOW_WEBHOOK_DELIVERY_ERROR_CODES.ENDPOINT_MISSING,
|
|
303
|
+
detail: `${row.endpointKey}@${row.endpointVersion} is not registered`,
|
|
304
|
+
kind: 'FAIL',
|
|
305
|
+
status: null,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
// Checked per attempt: disabling an endpoint stops what is already queued.
|
|
309
|
+
if (lookup.entry.endpoint.descriptor.disabled) {
|
|
310
|
+
return {
|
|
311
|
+
code: workflow_webhook_delivery_enums_1.WORKFLOW_WEBHOOK_DELIVERY_ERROR_CODES.ENDPOINT_DISABLED,
|
|
312
|
+
detail: `${row.endpointKey}@${row.endpointVersion} is disabled`,
|
|
313
|
+
kind: 'FAIL',
|
|
314
|
+
status: null,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
return this.sendEvent(lookup.entry, {
|
|
318
|
+
...row.event,
|
|
319
|
+
attempt,
|
|
320
|
+
deliveryId: row.id,
|
|
321
|
+
eventType: 'workflow.notify',
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Sends one event to an endpoint exactly as a delivery attempt would —
|
|
326
|
+
* same refusals, allowlist, timeout, redirect handling and signature —
|
|
327
|
+
* without a delivery row, for an administrator's test send (ADR 18 §3.13).
|
|
328
|
+
* The endpoint may be disabled: testing before enabling is the point.
|
|
329
|
+
*/
|
|
330
|
+
async sendTestEvent(entry, event) {
|
|
331
|
+
const outcome = await this.sendEvent(entry, event).catch((error) => ({
|
|
332
|
+
code: workflow_webhook_delivery_enums_1.WORKFLOW_WEBHOOK_DELIVERY_ERROR_CODES.INTERNAL_ERROR,
|
|
333
|
+
detail: readErrorName(error),
|
|
334
|
+
kind: 'FAIL',
|
|
335
|
+
status: null,
|
|
336
|
+
}));
|
|
337
|
+
return outcome.kind === 'SENT'
|
|
338
|
+
? { errorCode: null, errorDetail: null, ok: true, status: outcome.status }
|
|
339
|
+
: {
|
|
340
|
+
errorCode: outcome.code,
|
|
341
|
+
errorDetail: outcome.detail ? truncate(outcome.detail) : null,
|
|
342
|
+
ok: false,
|
|
343
|
+
status: outcome.status,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
async sendEvent(entry, event) {
|
|
347
|
+
// Wrapped so a host that throws synchronously, or returns a non-promise,
|
|
348
|
+
// is judged like any other failure instead of escaping the attempt.
|
|
349
|
+
const built = await (0, outbox_1.withDispatchTimeout)(Promise.resolve().then(() => entry.endpoint.buildRequest(event)), workflow_webhook_options_1.WORKFLOW_WEBHOOK_MAX_TIMEOUT_MS).then((value) => ({ value }), (error) => ({ error }));
|
|
350
|
+
if ('error' in built) {
|
|
351
|
+
return {
|
|
352
|
+
code: workflow_webhook_delivery_enums_1.WORKFLOW_WEBHOOK_DELIVERY_ERROR_CODES.BUILD_REQUEST_FAILED,
|
|
353
|
+
// The host's message may name the secret it failed to read, so only
|
|
354
|
+
// the error's kind is kept.
|
|
355
|
+
detail: readErrorName(built.error),
|
|
356
|
+
kind: 'RETRY',
|
|
357
|
+
status: null,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
if (!isWebhookRequest(built.value)) {
|
|
361
|
+
return {
|
|
362
|
+
code: workflow_webhook_delivery_enums_1.WORKFLOW_WEBHOOK_DELIVERY_ERROR_CODES.INVALID_REQUEST,
|
|
363
|
+
detail: 'buildRequest did not return a request with a string url',
|
|
364
|
+
kind: 'FAIL',
|
|
365
|
+
status: null,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
const request = built.value;
|
|
369
|
+
const refusal = this.readRequestRefusal(request, entry);
|
|
370
|
+
if (refusal) {
|
|
371
|
+
return refusal;
|
|
372
|
+
}
|
|
373
|
+
const body = request.body ?? JSON.stringify(event);
|
|
374
|
+
const timeoutMs = Math.min(typeof request.timeoutMs === 'number' && request.timeoutMs > 0
|
|
375
|
+
? request.timeoutMs
|
|
376
|
+
: this.options.delivery.defaultTimeoutMs, workflow_webhook_options_1.WORKFLOW_WEBHOOK_MAX_TIMEOUT_MS);
|
|
377
|
+
return this.post({
|
|
378
|
+
body,
|
|
379
|
+
// Signed at the moment of sending, not when the row was claimed, so a
|
|
380
|
+
// receiver's replay window measures the request it actually got.
|
|
381
|
+
headers: buildHeaders(request, body, event.deliveryId, this.readCurrentTime()),
|
|
382
|
+
method: request.method ?? 'POST',
|
|
383
|
+
timeoutMs,
|
|
384
|
+
url: request.url,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* A request BPM refuses to send at all. These never become retryable: the
|
|
389
|
+
* same endpoint would return the same request next time. None of the
|
|
390
|
+
* details quote the URL.
|
|
391
|
+
*/
|
|
392
|
+
readRequestRefusal(request, entry) {
|
|
393
|
+
const invalid = (detail) => ({
|
|
394
|
+
code: workflow_webhook_delivery_enums_1.WORKFLOW_WEBHOOK_DELIVERY_ERROR_CODES.INVALID_REQUEST,
|
|
395
|
+
detail,
|
|
396
|
+
kind: 'FAIL',
|
|
397
|
+
status: null,
|
|
398
|
+
});
|
|
399
|
+
const parsed = (() => {
|
|
400
|
+
try {
|
|
401
|
+
return new URL(request.url);
|
|
402
|
+
}
|
|
403
|
+
catch {
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
})();
|
|
407
|
+
if (!parsed ||
|
|
408
|
+
(parsed.protocol !== 'https:' && parsed.protocol !== 'http:')) {
|
|
409
|
+
return invalid('buildRequest returned a URL that is not http(s)');
|
|
410
|
+
}
|
|
411
|
+
if (parsed.username || parsed.password) {
|
|
412
|
+
return invalid('buildRequest returned a URL that carries credentials');
|
|
413
|
+
}
|
|
414
|
+
if (request.method !== undefined &&
|
|
415
|
+
!ALLOWED_METHODS.includes(String(request.method))) {
|
|
416
|
+
return invalid('buildRequest returned a method other than POST, PUT or PATCH');
|
|
417
|
+
}
|
|
418
|
+
if (request.body !== undefined && typeof request.body !== 'string') {
|
|
419
|
+
return invalid('buildRequest returned a body that is not a string');
|
|
420
|
+
}
|
|
421
|
+
const enforceAllowlist = entry.source === 'DATABASE' || this.options.enforceAllowlistForRegistry;
|
|
422
|
+
// Checked on every attempt, not only when the endpoint was saved, so
|
|
423
|
+
// tightening the allowlist stops deliveries that are already queued.
|
|
424
|
+
if (enforceAllowlist &&
|
|
425
|
+
!(0, workflow_webhook_allowlist_1.isWorkflowWebhookUrlAllowed)(request.url, this.options.allowedUrlPatterns)) {
|
|
426
|
+
return {
|
|
427
|
+
code: workflow_webhook_delivery_enums_1.WORKFLOW_WEBHOOK_DELIVERY_ERROR_CODES.URL_NOT_ALLOWED,
|
|
428
|
+
detail: null,
|
|
429
|
+
kind: 'FAIL',
|
|
430
|
+
status: null,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
435
|
+
async post({ body, headers, method, timeoutMs, url, }) {
|
|
436
|
+
const controller = new AbortController();
|
|
437
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
438
|
+
timer.unref();
|
|
439
|
+
try {
|
|
440
|
+
const response = await this.fetchImpl(url, {
|
|
441
|
+
body,
|
|
442
|
+
headers,
|
|
443
|
+
method,
|
|
444
|
+
// A 3xx is a failure, never followed: an allowed host must not be
|
|
445
|
+
// able to bounce the request somewhere the allowlist would refuse.
|
|
446
|
+
redirect: 'manual',
|
|
447
|
+
signal: controller.signal,
|
|
448
|
+
});
|
|
449
|
+
const status = response.status;
|
|
450
|
+
if (status >= 200 && status < 300) {
|
|
451
|
+
await discardBody(response);
|
|
452
|
+
return { kind: 'SENT', status };
|
|
453
|
+
}
|
|
454
|
+
// `redirect: 'manual'` surfaces as an opaque redirect with status 0 in
|
|
455
|
+
// spec-compliant fetch implementations and as the raw 3xx in Node's.
|
|
456
|
+
if (response.type === 'opaqueredirect' ||
|
|
457
|
+
(status >= 300 && status < 400)) {
|
|
458
|
+
await discardBody(response);
|
|
459
|
+
return {
|
|
460
|
+
code: workflow_webhook_delivery_enums_1.WORKFLOW_WEBHOOK_DELIVERY_ERROR_CODES.REDIRECT,
|
|
461
|
+
detail: null,
|
|
462
|
+
kind: 'FAIL',
|
|
463
|
+
status: status || null,
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
const detail = truncate(await readBodyPrefix(response));
|
|
467
|
+
return {
|
|
468
|
+
code: `WEBHOOK_HTTP_${status}`,
|
|
469
|
+
detail: detail || null,
|
|
470
|
+
kind: isRetryableStatus(status) ? 'RETRY' : 'FAIL',
|
|
471
|
+
status,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
catch (error) {
|
|
475
|
+
const aborted = controller.signal.aborted ||
|
|
476
|
+
(error instanceof Error && error.name === 'AbortError');
|
|
477
|
+
return {
|
|
478
|
+
code: aborted
|
|
479
|
+
? workflow_webhook_delivery_enums_1.WORKFLOW_WEBHOOK_DELIVERY_ERROR_CODES.TIMEOUT
|
|
480
|
+
: workflow_webhook_delivery_enums_1.WORKFLOW_WEBHOOK_DELIVERY_ERROR_CODES.NETWORK,
|
|
481
|
+
// A fetch error message can quote the URL; the system error code
|
|
482
|
+
// (ECONNREFUSED, ENOTFOUND, ...) says what happened without it.
|
|
483
|
+
detail: aborted ? null : readNetworkErrorCode(error),
|
|
484
|
+
kind: 'RETRY',
|
|
485
|
+
status: null,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
finally {
|
|
489
|
+
clearTimeout(timer);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
async record(row, attempt, outcome, finishedAt) {
|
|
493
|
+
const exhausted = outcome.kind === 'RETRY' && attempt >= this.options.delivery.maxAttempts;
|
|
494
|
+
const terminal = outcome.kind !== 'RETRY' || exhausted;
|
|
495
|
+
const changes = outcome.kind === 'SENT'
|
|
496
|
+
? {
|
|
497
|
+
attemptCount: attempt,
|
|
498
|
+
lastErrorCode: null,
|
|
499
|
+
lastErrorDetail: null,
|
|
500
|
+
lastResponseStatus: outcome.status,
|
|
501
|
+
nextRetryAt: null,
|
|
502
|
+
sentAt: finishedAt,
|
|
503
|
+
status: workflow_webhook_delivery_enums_1.WorkflowWebhookDeliveryStatusEnum.SENT,
|
|
504
|
+
}
|
|
505
|
+
: {
|
|
506
|
+
attemptCount: attempt,
|
|
507
|
+
lastErrorCode: outcome.code,
|
|
508
|
+
// Every detail source passes through here, so every one is made
|
|
509
|
+
// storable — including a host error's `name`.
|
|
510
|
+
lastErrorDetail: outcome.detail === null ? null : truncate(String(outcome.detail)),
|
|
511
|
+
lastResponseStatus: outcome.status,
|
|
512
|
+
nextRetryAt: terminal
|
|
513
|
+
? null
|
|
514
|
+
: new Date(finishedAt.getTime() + this.readRetryDelay(attempt)),
|
|
515
|
+
status: terminal
|
|
516
|
+
? workflow_webhook_delivery_enums_1.WorkflowWebhookDeliveryStatusEnum.FAILED
|
|
517
|
+
: workflow_webhook_delivery_enums_1.WorkflowWebhookDeliveryStatusEnum.PENDING,
|
|
518
|
+
};
|
|
519
|
+
// Written only if this worker still holds the claim it started with, so a
|
|
520
|
+
// late write-back can never overwrite what another worker already
|
|
521
|
+
// recorded — a SENT row least of all.
|
|
522
|
+
const result = await this.deliveryRepository.update({
|
|
523
|
+
id: row.id,
|
|
524
|
+
lastAttemptAt: row.lastAttemptAt ?? (0, typeorm_2.IsNull)(),
|
|
525
|
+
status: workflow_webhook_delivery_enums_1.WorkflowWebhookDeliveryStatusEnum.DELIVERY_IN_PROGRESS,
|
|
526
|
+
}, changes);
|
|
527
|
+
if (!result.affected) {
|
|
528
|
+
this.logger.warn(`Webhook delivery ${row.id} was reclaimed before attempt ${attempt} finished; its outcome was discarded`);
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
// Intermediate retries stay off the instance timeline; only the final
|
|
532
|
+
// word about a delivery is worth a reader's attention (ADR 18 §3.10).
|
|
533
|
+
if (terminal) {
|
|
534
|
+
// The outcome is already recorded; a failed timeline write must not be
|
|
535
|
+
// reported as if the delivery itself were unrecorded.
|
|
536
|
+
const endpointLabel = await this.readEndpointLabel(row);
|
|
537
|
+
await this.activityLogRepository
|
|
538
|
+
.save(this.activityLogRepository.create(this.createTerminalActivity({ ...row, ...changes }, endpointLabel)))
|
|
539
|
+
.catch((error) => {
|
|
540
|
+
this.logger.error(`Webhook delivery ${row.id} is ${changes.status} but its activity log could not be written (${readErrorName(error)})`);
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* The endpoint's display name for the timeline, which every reader of the
|
|
546
|
+
* instance sees; `null` when the endpoint is gone and the key must stand in.
|
|
547
|
+
*/
|
|
548
|
+
async readEndpointLabel(row, manager) {
|
|
549
|
+
// Every step is guarded: a host entry missing its descriptor must cost
|
|
550
|
+
// the label, not the activity log it is written into.
|
|
551
|
+
return Promise.resolve()
|
|
552
|
+
.then(() => this.webhookService.getEndpoint(row.endpointKey, row.endpointVersion, manager))
|
|
553
|
+
.then((entry) => {
|
|
554
|
+
const label = entry?.endpoint?.descriptor?.label;
|
|
555
|
+
return typeof label === 'string' ? label : null;
|
|
556
|
+
})
|
|
557
|
+
.catch(() => null);
|
|
558
|
+
}
|
|
559
|
+
/** Takes the claim over from `from` to `to`; `false` if it moved on. */
|
|
560
|
+
async moveClaim(row, from, to) {
|
|
561
|
+
const result = await this.deliveryRepository.update({
|
|
562
|
+
id: row.id,
|
|
563
|
+
lastAttemptAt: from ?? (0, typeorm_2.IsNull)(),
|
|
564
|
+
status: workflow_webhook_delivery_enums_1.WorkflowWebhookDeliveryStatusEnum.DELIVERY_IN_PROGRESS,
|
|
565
|
+
}, { lastAttemptAt: to });
|
|
566
|
+
return Boolean(result.affected);
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* `base * 2^(attempt - 1)` with ±20 % jitter so a receiver that came back up
|
|
570
|
+
* is not hit by every queued delivery in the same second, then capped: the
|
|
571
|
+
* cap is a promise to the operator, so jitter may not exceed it.
|
|
572
|
+
*/
|
|
573
|
+
readRetryDelay(attempt) {
|
|
574
|
+
const { maxRetryDelayMs, retryBaseDelayMs } = this.options.delivery;
|
|
575
|
+
const exponential = retryBaseDelayMs * 2 ** Math.min(Math.max(attempt - 1, 0), 30);
|
|
576
|
+
return Math.min(Math.round(exponential * (0.8 + Math.random() * 0.4)), maxRetryDelayMs);
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Visible to everyone who can read the instance, so it carries the outcome
|
|
580
|
+
* and nothing else: no URL, no response body, no parameters.
|
|
581
|
+
*/
|
|
582
|
+
createTerminalActivity(row, endpointLabel) {
|
|
583
|
+
return {
|
|
584
|
+
actorMemberId: null,
|
|
585
|
+
eventType: row.status === workflow_webhook_delivery_enums_1.WorkflowWebhookDeliveryStatusEnum.SENT
|
|
586
|
+
? workflow_engine_enums_1.ActivityLogEventTypeEnum.SERVICE_TASK_EXECUTED
|
|
587
|
+
: workflow_engine_enums_1.ActivityLogEventTypeEnum.SERVICE_TASK_FAILED,
|
|
588
|
+
instanceId: row.instanceId,
|
|
589
|
+
nodeId: row.nodeId,
|
|
590
|
+
payload: {
|
|
591
|
+
action: 'NOTIFY_WEBHOOK',
|
|
592
|
+
attempts: row.attemptCount,
|
|
593
|
+
deliveryId: row.id,
|
|
594
|
+
endpointKey: row.endpointKey,
|
|
595
|
+
endpointLabel,
|
|
596
|
+
endpointVersion: row.endpointVersion,
|
|
597
|
+
errorCode: row.lastErrorCode,
|
|
598
|
+
status: row.lastResponseStatus,
|
|
599
|
+
targetId: row.targetId,
|
|
600
|
+
},
|
|
601
|
+
taskId: null,
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
};
|
|
605
|
+
exports.WorkflowWebhookDeliveryService = WorkflowWebhookDeliveryService;
|
|
606
|
+
exports.WorkflowWebhookDeliveryService = WorkflowWebhookDeliveryService = WorkflowWebhookDeliveryService_1 = tslib_1.__decorate([
|
|
607
|
+
(0, common_1.Injectable)(),
|
|
608
|
+
tslib_1.__param(0, (0, typeorm_1.InjectRepository)(workflow_webhook_delivery_entity_1.WorkflowWebhookDeliveryEntity)),
|
|
609
|
+
tslib_1.__param(1, (0, typeorm_1.InjectRepository)(activity_log_entity_1.ActivityLogEntity)),
|
|
610
|
+
tslib_1.__param(3, (0, common_1.Optional)()),
|
|
611
|
+
tslib_1.__param(3, (0, common_1.Inject)(workflow_webhook_options_1.BPM_WORKFLOW_WEBHOOK_OPTIONS)),
|
|
612
|
+
tslib_1.__param(4, (0, common_1.Optional)()),
|
|
613
|
+
tslib_1.__param(4, (0, common_1.Inject)(exports.BPM_WORKFLOW_WEBHOOK_FETCH)),
|
|
614
|
+
tslib_1.__metadata("design:paramtypes", [typeorm_2.Repository,
|
|
615
|
+
typeorm_2.Repository,
|
|
616
|
+
workflow_webhook_service_1.WorkflowWebhookService, Object, Function])
|
|
617
|
+
], WorkflowWebhookDeliveryService);
|
|
618
|
+
function isWebhookRequest(value) {
|
|
619
|
+
return (typeof value === 'object' &&
|
|
620
|
+
value !== null &&
|
|
621
|
+
typeof value.url === 'string');
|
|
622
|
+
}
|
|
623
|
+
function buildHeaders(request, body, deliveryId, sentAt) {
|
|
624
|
+
const hostHeaders = Object.fromEntries(Object.entries(request.headers ?? {}).filter(([name, value]) => typeof value === 'string' && !name.toLowerCase().startsWith('x-bpm-')));
|
|
625
|
+
const hasContentType = Object.keys(hostHeaders).some((name) => name.toLowerCase() === 'content-type');
|
|
626
|
+
const timestamp = String(Math.floor(sentAt.getTime() / 1000));
|
|
627
|
+
// BPM's own headers are applied last and a host cannot supply an
|
|
628
|
+
// `x-bpm-*` header, so a receiver can trust them.
|
|
629
|
+
return {
|
|
630
|
+
...(hasContentType ? {} : { 'content-type': 'application/json' }),
|
|
631
|
+
...hostHeaders,
|
|
632
|
+
'x-bpm-delivery-id': deliveryId,
|
|
633
|
+
'x-bpm-event': 'workflow.notify',
|
|
634
|
+
...(request.signingSecret
|
|
635
|
+
? {
|
|
636
|
+
'x-bpm-signature-sha256': (0, node_crypto_1.createHmac)('sha256', request.signingSecret)
|
|
637
|
+
.update(`${timestamp}.${body}`)
|
|
638
|
+
.digest('hex'),
|
|
639
|
+
'x-bpm-timestamp': timestamp,
|
|
640
|
+
}
|
|
641
|
+
: {}),
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
/** Releases the connection without reading a body nobody will look at. */
|
|
645
|
+
async function discardBody(response) {
|
|
646
|
+
await response.body?.cancel().catch(() => undefined);
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Reads at most {@link MAX_ERROR_BODY_BYTES} of a failing response, so a
|
|
650
|
+
* receiver answering with megabytes of HTML cannot make BPM buffer them.
|
|
651
|
+
*/
|
|
652
|
+
async function readBodyPrefix(response) {
|
|
653
|
+
const reader = response.body?.getReader();
|
|
654
|
+
if (!reader) {
|
|
655
|
+
return response.text().catch(() => '');
|
|
656
|
+
}
|
|
657
|
+
const chunks = [];
|
|
658
|
+
let received = 0;
|
|
659
|
+
try {
|
|
660
|
+
while (received < MAX_ERROR_BODY_BYTES) {
|
|
661
|
+
const { done, value } = await reader.read();
|
|
662
|
+
if (done || !value) {
|
|
663
|
+
break;
|
|
664
|
+
}
|
|
665
|
+
chunks.push(value);
|
|
666
|
+
received += value.byteLength;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
catch {
|
|
670
|
+
// A body that fails mid-read still says something; keep what arrived.
|
|
671
|
+
}
|
|
672
|
+
finally {
|
|
673
|
+
await reader.cancel().catch(() => undefined);
|
|
674
|
+
}
|
|
675
|
+
return new TextDecoder()
|
|
676
|
+
.decode(Buffer.concat(chunks).subarray(0, MAX_ERROR_BODY_BYTES))
|
|
677
|
+
.replace(/�+$/u, '');
|
|
678
|
+
}
|
|
679
|
+
function readErrorName(error) {
|
|
680
|
+
return error instanceof Error ? error.name : typeof error;
|
|
681
|
+
}
|
|
682
|
+
function readNetworkErrorCode(error) {
|
|
683
|
+
const cause = typeof error === 'object' && error !== null && 'cause' in error
|
|
684
|
+
? error.cause
|
|
685
|
+
: undefined;
|
|
686
|
+
const code = typeof cause === 'object' && cause !== null && 'code' in cause
|
|
687
|
+
? cause.code
|
|
688
|
+
: undefined;
|
|
689
|
+
return typeof code === 'string' ? code : readErrorName(error);
|
|
690
|
+
}
|
|
691
|
+
function isRetryableStatus(status) {
|
|
692
|
+
return status === 408 || status === 429 || status >= 500;
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Makes a receiver-supplied string safe to store: PostgreSQL `text` rejects
|
|
696
|
+
* NUL, and a write that always fails would leave the row claimed and resent
|
|
697
|
+
* forever instead of counting towards `maxAttempts`.
|
|
698
|
+
*/
|
|
699
|
+
function truncate(value) {
|
|
700
|
+
const storable = value.split('\u0000').join('');
|
|
701
|
+
return storable.length > MAX_ERROR_DETAIL_LENGTH
|
|
702
|
+
? storable.slice(0, MAX_ERROR_DETAIL_LENGTH)
|
|
703
|
+
: storable;
|
|
704
|
+
}
|
|
705
|
+
//# sourceMappingURL=workflow-webhook-delivery.service.js.map
|