@dudousxd/nestjs-notifications-webhook 0.6.0 → 0.7.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/dist/index.js CHANGED
@@ -7,7 +7,6 @@ var WebhookMessage = class {
7
7
  __name(this, "WebhookMessage");
8
8
  }
9
9
  _url;
10
- _method = "POST";
11
10
  _headers = {};
12
11
  _payload = {};
13
12
  /** Set the target URL (overrides the route and configured default). */
@@ -33,17 +32,9 @@ var WebhookMessage = class {
33
32
  };
34
33
  return this;
35
34
  }
36
- /** Set the HTTP method (default `POST`). */
37
- method(m) {
38
- this._method = m;
39
- return this;
40
- }
41
35
  get urlValue() {
42
36
  return this._url;
43
37
  }
44
- get methodValue() {
45
- return this._method;
46
- }
47
38
  get headerValues() {
48
39
  return this._headers;
49
40
  }
@@ -54,7 +45,6 @@ var WebhookMessage = class {
54
45
  toRequest() {
55
46
  return {
56
47
  url: this._url,
57
- method: this._method,
58
48
  headers: {
59
49
  ...this._headers
60
50
  },
@@ -62,15 +52,51 @@ var WebhookMessage = class {
62
52
  };
63
53
  }
64
54
  };
55
+ var WebhookEvent = class {
56
+ static {
57
+ __name(this, "WebhookEvent");
58
+ }
59
+ type;
60
+ data;
61
+ constructor(type, data) {
62
+ this.type = type;
63
+ this.data = data;
64
+ }
65
+ };
65
66
 
66
67
  // src/webhook.channel.ts
67
- import { createHmac } from "crypto";
68
- import { BaseChannel, createChannel, postJson, routeFor } from "@dudousxd/nestjs-notifications-core";
68
+ import { createHash, randomUUID } from "crypto";
69
+ import { BaseChannel, createChannel, routeFor } from "@dudousxd/nestjs-notifications-core";
69
70
  import { Inject, Injectable, Optional } from "@nestjs/common";
71
+ import { OutboxInbox } from "@nestjs/outbox";
72
+ import { HttpWebhookTransport, Webhooks } from "@nestjs/webhooks";
73
+
74
+ // src/signing.ts
75
+ import { createHmac } from "crypto";
76
+ function standardWebhookHeaders(secret, id, body, now = /* @__PURE__ */ new Date()) {
77
+ const timestamp = Math.floor(now.getTime() / 1e3);
78
+ const encoded = secret.startsWith("whsec_") ? secret.slice("whsec_".length) : secret;
79
+ const key = Buffer.from(encoded, "base64");
80
+ if (key.length < 24 || key.length > 64) {
81
+ throw new TypeError("A Standard Webhooks secret is `whsec_` followed by base64 of 24 to 64 random bytes.");
82
+ }
83
+ const signature = createHmac("sha256", key).update(`${id}.${timestamp}.`).update(body).digest("base64");
84
+ return {
85
+ "webhook-id": id,
86
+ "webhook-timestamp": String(timestamp),
87
+ "webhook-signature": `v1,${signature}`
88
+ };
89
+ }
90
+ __name(standardWebhookHeaders, "standardWebhookHeaders");
91
+ function sha256Signature(secret, body) {
92
+ return `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`;
93
+ }
94
+ __name(sha256Signature, "sha256Signature");
70
95
 
71
96
  // src/tokens.ts
72
97
  var WEBHOOK_OPTIONS = /* @__PURE__ */ Symbol("WEBHOOK_OPTIONS");
73
98
  var WEBHOOK_OPTIONS_RESOLVER = /* @__PURE__ */ Symbol("WEBHOOK_OPTIONS_RESOLVER");
99
+ var WEBHOOK_TRANSACTION = /* @__PURE__ */ Symbol("WEBHOOK_TRANSACTION");
74
100
 
75
101
  // src/webhook.channel.ts
76
102
  function _ts_decorate(decorators, target, key, desc) {
@@ -100,6 +126,9 @@ function _ts_param(paramIndex, decorator) {
100
126
  }
101
127
  __name(_ts_param, "_ts_param");
102
128
  var DEFAULT_SIGNATURE_HEADER = "X-Signature-256";
129
+ var DEFAULT_TIMEOUT_MS = 15e3;
130
+ var MAX_CACHED_TRANSPORTS = 1e3;
131
+ var WEBHOOK_DISPATCH_CONSUMER = "aviary.notifications.webhook-dispatch";
103
132
  var Webhook = createChannel("webhook");
104
133
  var WebhookChannel = class extends BaseChannel {
105
134
  static {
@@ -107,13 +136,23 @@ var WebhookChannel = class extends BaseChannel {
107
136
  }
108
137
  options;
109
138
  resolveOptions;
139
+ transaction;
140
+ webhooks;
141
+ inbox;
110
142
  channel = "webhook";
111
- constructor(options, resolveOptions) {
112
- super(), this.options = options, this.resolveOptions = resolveOptions;
143
+ /**
144
+ * `HttpWebhookTransport`s by tenant (`''` without one), rebuilt only when that tenant's
145
+ * `delivery` options change. Keyed by tenant rather than by the resolved options object, since a
146
+ * resolver may return a fresh object on every call.
147
+ */
148
+ transports = /* @__PURE__ */ new Map();
149
+ constructor(options, resolveOptions, transaction, webhooks, inbox) {
150
+ super(), this.options = options, this.resolveOptions = resolveOptions, this.transaction = transaction, this.webhooks = webhooks, this.inbox = inbox;
113
151
  }
114
152
  async send(notifiable, notification, context) {
115
- const options = this.forTenant(this.options, context, this.resolveOptions);
116
153
  const result = this.buildPayload(notification, notifiable, "toWebhook", context);
154
+ if (result instanceof WebhookEvent) return this.dispatch(result, context);
155
+ const options = this.forTenant(this.options, context, this.resolveOptions);
117
156
  const message = result instanceof WebhookMessage ? result : new WebhookMessage().payload(result);
118
157
  const request = message.toRequest();
119
158
  const route = routeFor(notifiable, "webhook", notification);
@@ -121,25 +160,87 @@ var WebhookChannel = class extends BaseChannel {
121
160
  if (!url) {
122
161
  throw new Error('The webhook channel needs a target URL. Set one on the WebhookMessage via .url(), return one from routeNotificationFor("webhook"), or set url in forRoot().');
123
162
  }
163
+ const body = JSON.stringify(request.body);
164
+ const id = context?.idempotencyKey ?? `msg_${randomUUID().replaceAll("-", "")}`;
124
165
  const headers = {
125
- ...options.headers,
126
- ...request.headers
166
+ "content-type": "application/json",
167
+ ...lowerCase(options.headers),
168
+ ...lowerCase(request.headers)
127
169
  };
128
170
  if (options.secret) {
129
- const signature = createHmac("sha256", options.secret).update(JSON.stringify(request.body)).digest("hex");
130
- headers[options.signatureHeader ?? DEFAULT_SIGNATURE_HEADER] = `sha256=${signature}`;
171
+ if (options.signing === "standard") {
172
+ Object.assign(headers, standardWebhookHeaders(options.secret, id, body));
173
+ } else {
174
+ headers[(options.signatureHeader ?? DEFAULT_SIGNATURE_HEADER).toLowerCase()] = sha256Signature(options.secret, body);
175
+ }
131
176
  }
132
- await postJson(url, request.body, {
133
- label: "Webhook",
134
- method: request.method,
177
+ const response = await this.transportFor(options, context?.tenant).send({
178
+ url,
135
179
  headers,
136
- ...options.timeoutMs !== void 0 ? {
137
- timeoutMs: options.timeoutMs
138
- } : {},
139
- ...options.redirect !== void 0 ? {
140
- redirect: options.redirect
141
- } : {}
180
+ body,
181
+ endpointId: "aviary",
182
+ deliveryId: id,
183
+ messageId: id,
184
+ type: "notification"
185
+ }, {
186
+ signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
187
+ attempt: 1
142
188
  });
189
+ if (response.statusCode < 200 || response.statusCode >= 300) {
190
+ throw new Error(`Webhook request to ${url} failed with status ${response.statusCode}.`);
191
+ }
192
+ return {
193
+ id,
194
+ statusCode: response.statusCode
195
+ };
196
+ }
197
+ async dispatch(event, context) {
198
+ const webhooks = this.webhooks;
199
+ if (!webhooks) {
200
+ throw new Error("A WebhookEvent needs WebhooksModule (and OutboxModule) from @nestjs/webhooks.");
201
+ }
202
+ if (!this.transaction) {
203
+ throw new Error("A WebhookEvent needs WebhookChannelModule.forRoot({ transaction: (work) => db.transaction(work) }): @nestjs/webhooks writes the event through a transaction.");
204
+ }
205
+ const id = context?.idempotencyKey ? webhookId(context.idempotencyKey) : void 0;
206
+ const inbox = this.inbox;
207
+ const outcome = await this.transaction(async (tx) => {
208
+ const dispatch = /* @__PURE__ */ __name(() => webhooks.dispatch(tx, {
209
+ type: event.type,
210
+ data: event.data,
211
+ tenant: context?.tenant ?? null,
212
+ ...id ? {
213
+ id
214
+ } : {}
215
+ }), "dispatch");
216
+ if (!id || !inbox) return {
217
+ duplicate: false,
218
+ result: await dispatch()
219
+ };
220
+ return inbox.processInTransaction(tx, WEBHOOK_DISPATCH_CONSUMER, id, dispatch);
221
+ });
222
+ if (outcome.duplicate) return {
223
+ id,
224
+ duplicate: true
225
+ };
226
+ webhooks.notify();
227
+ return outcome.result;
228
+ }
229
+ transportFor(options, tenant) {
230
+ if (options.transport) return options.transport;
231
+ const key = tenant && this.resolveOptions ? tenant : "";
232
+ const cached = this.transports.get(key);
233
+ if (cached && sameDelivery(cached.delivery, options.delivery)) return cached.transport;
234
+ const transport = new HttpWebhookTransport(options.delivery);
235
+ this.transports.delete(key);
236
+ this.transports.set(key, {
237
+ delivery: options.delivery,
238
+ transport
239
+ });
240
+ while (this.transports.size > MAX_CACHED_TRANSPORTS) {
241
+ this.transports.delete(this.transports.keys().next().value);
242
+ }
243
+ return transport;
143
244
  }
144
245
  };
145
246
  WebhookChannel = _ts_decorate([
@@ -147,12 +248,46 @@ WebhookChannel = _ts_decorate([
147
248
  _ts_param(0, Inject(WEBHOOK_OPTIONS)),
148
249
  _ts_param(1, Optional()),
149
250
  _ts_param(1, Inject(WEBHOOK_OPTIONS_RESOLVER)),
251
+ _ts_param(2, Optional()),
252
+ _ts_param(2, Inject(WEBHOOK_TRANSACTION)),
253
+ _ts_param(3, Optional()),
254
+ _ts_param(4, Optional()),
150
255
  _ts_metadata("design:type", Function),
151
256
  _ts_metadata("design:paramtypes", [
152
257
  typeof WebhookChannelOptions === "undefined" ? Object : WebhookChannelOptions,
153
- typeof WebhookOptionsResolver === "undefined" ? Object : WebhookOptionsResolver
258
+ typeof WebhookOptionsResolver === "undefined" ? Object : WebhookOptionsResolver,
259
+ typeof WebhookTransactionRunner === "undefined" ? Object : WebhookTransactionRunner,
260
+ typeof Webhooks === "undefined" ? Object : Webhooks,
261
+ typeof OutboxInbox === "undefined" ? Object : OutboxInbox
154
262
  ])
155
263
  ], WebhookChannel);
264
+ function lowerCase(headers) {
265
+ const out = {};
266
+ for (const [name, value] of Object.entries(headers ?? {})) out[name.toLowerCase()] = value;
267
+ return out;
268
+ }
269
+ __name(lowerCase, "lowerCase");
270
+ function sameDelivery(a, b) {
271
+ if (a === b) return true;
272
+ if (!a || !b) return false;
273
+ const keys = Object.keys(a);
274
+ if (keys.length !== Object.keys(b).length) return false;
275
+ return keys.every((key) => {
276
+ const x = a[key];
277
+ const y = b[key];
278
+ if (Array.isArray(x) && Array.isArray(y)) {
279
+ return x.length === y.length && x.every((item, i) => Object.is(item, y[i]));
280
+ }
281
+ return Object.is(x, y);
282
+ });
283
+ }
284
+ __name(sameDelivery, "sameDelivery");
285
+ function webhookId(key) {
286
+ const kept = `msg_${key}`;
287
+ if (/^[A-Za-z0-9_-]+$/.test(key) && kept.length <= 128) return kept;
288
+ return `msgh_${createHash("sha256").update(key).digest("hex")}`;
289
+ }
290
+ __name(webhookId, "webhookId");
156
291
 
157
292
  // src/webhook.module.ts
158
293
  import { defineChannelModule } from "@dudousxd/nestjs-notifications-core";
@@ -176,52 +311,46 @@ var WebhookChannelModule = class _WebhookChannelModule {
176
311
  __name(this, "WebhookChannelModule");
177
312
  }
178
313
  static forRoot(options = {}) {
179
- const webhookOptions = {
180
- // Set each field only when provided (exactOptionalPropertyTypes); the channel resolves a
181
- // per-notifiable URL and default headers when these are absent.
182
- ...options.url !== void 0 ? {
183
- url: options.url
184
- } : {},
185
- ...options.headers !== void 0 ? {
186
- headers: options.headers
187
- } : {},
188
- ...options.secret !== void 0 ? {
189
- secret: options.secret
190
- } : {},
191
- ...options.signatureHeader !== void 0 ? {
192
- signatureHeader: options.signatureHeader
193
- } : {},
194
- ...options.timeoutMs !== void 0 ? {
195
- timeoutMs: options.timeoutMs
196
- } : {},
197
- ...options.redirect !== void 0 ? {
198
- redirect: options.redirect
199
- } : {}
200
- };
201
- return defineChannelModule({
314
+ const { resolveOptions, transaction, global, ...channelOptions } = options;
315
+ const module = defineChannelModule({
202
316
  module: _WebhookChannelModule,
203
317
  channel: WebhookChannel,
204
318
  optionsToken: WEBHOOK_OPTIONS,
205
- options: webhookOptions,
319
+ options: channelOptions,
206
320
  resolver: {
207
321
  token: WEBHOOK_OPTIONS_RESOLVER,
208
- value: options.resolveOptions
322
+ value: resolveOptions
209
323
  },
210
- ...options.global !== void 0 ? {
211
- global: options.global
324
+ ...global !== void 0 ? {
325
+ global
212
326
  } : {}
213
327
  });
328
+ return {
329
+ ...module,
330
+ providers: [
331
+ ...module.providers ?? [],
332
+ {
333
+ provide: WEBHOOK_TRANSACTION,
334
+ useValue: transaction ?? null
335
+ }
336
+ ]
337
+ };
214
338
  }
215
339
  };
216
340
  WebhookChannelModule = _ts_decorate2([
217
341
  Module({})
218
342
  ], WebhookChannelModule);
219
343
  export {
344
+ WEBHOOK_DISPATCH_CONSUMER,
220
345
  WEBHOOK_OPTIONS,
221
346
  WEBHOOK_OPTIONS_RESOLVER,
347
+ WEBHOOK_TRANSACTION,
222
348
  Webhook,
223
349
  WebhookChannel,
224
350
  WebhookChannelModule,
225
- WebhookMessage
351
+ WebhookEvent,
352
+ WebhookMessage,
353
+ sha256Signature,
354
+ standardWebhookHeaders
226
355
  };
227
356
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/webhook-message.ts","../src/webhook.channel.ts","../src/tokens.ts","../src/webhook.module.ts"],"sourcesContent":["/** The HTTP method used for a webhook request. */\nexport type WebhookMethod = 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'GET';\n\n/** The resolved request description produced by {@link WebhookMessage.toRequest}. */\nexport interface WebhookRequest {\n /** Target URL, when set on the message; otherwise resolved by the channel. */\n url?: string | undefined;\n method: WebhookMethod;\n headers: Record<string, string>;\n /** The JSON body to send (a plain object). */\n body: Record<string, unknown>;\n}\n\n/**\n * A fluent builder for a webhook request.\n *\n * ```ts\n * new WebhookMessage()\n * .url('https://example.com/hooks/orders')\n * .header('X-Signature', sign(payload))\n * .payload({ event: 'order.paid', id: 42 });\n * ```\n */\nexport class WebhookMessage {\n private _url?: string;\n private _method: WebhookMethod = 'POST';\n private _headers: Record<string, string> = {};\n private _payload: Record<string, unknown> = {};\n\n /** Set the target URL (overrides the route and configured default). */\n url(u: string): this {\n this._url = u;\n return this;\n }\n\n /** Set the JSON body to send. */\n payload(obj: Record<string, unknown>): this {\n this._payload = obj;\n return this;\n }\n\n /** Set a single request header. */\n header(key: string, value: string): this {\n this._headers[key] = value;\n return this;\n }\n\n /** Merge multiple request headers. */\n headers(obj: Record<string, string>): this {\n this._headers = { ...this._headers, ...obj };\n return this;\n }\n\n /** Set the HTTP method (default `POST`). */\n method(m: WebhookMethod): this {\n this._method = m;\n return this;\n }\n\n get urlValue(): string | undefined {\n return this._url;\n }\n\n get methodValue(): WebhookMethod {\n return this._method;\n }\n\n get headerValues(): Record<string, string> {\n return this._headers;\n }\n\n get payloadValue(): Record<string, unknown> {\n return this._payload;\n }\n\n /** Serialize to a {@link WebhookRequest}. */\n toRequest(): WebhookRequest {\n return {\n url: this._url,\n method: this._method,\n headers: { ...this._headers },\n body: this._payload,\n };\n }\n}\n","import { createHmac } from 'node:crypto';\nimport {\n BaseChannel,\n type ChannelContext,\n type DeliveryContext,\n type Notifiable,\n type Notification,\n createChannel,\n postJson,\n routeFor,\n} from '@dudousxd/nestjs-notifications-core';\nimport { Inject, Injectable, Optional } from '@nestjs/common';\nimport { WEBHOOK_OPTIONS, WEBHOOK_OPTIONS_RESOLVER } from './tokens';\nimport { WebhookMessage } from './webhook-message';\n\nconst DEFAULT_SIGNATURE_HEADER = 'X-Signature-256';\n\n/** Channel handle: use as `@Webhook()` on a payload method, or as a token in `via()`. */\nexport const Webhook = createChannel('webhook');\n\n/** Resolved runtime options for the webhook channel. */\nexport interface WebhookChannelOptions {\n /** Default URL used when neither the message nor the route supplies one. */\n url?: string;\n /** Default headers merged into every request. */\n headers?: Record<string, string>;\n /**\n * HMAC-SHA256 secret. When set, the request is signed: the header (default\n * `X-Signature-256`) is set to `sha256=<hex digest of the raw JSON body>`, so the receiver\n * can verify authenticity. Per-tenant secrets work via `resolveOptions`.\n */\n secret?: string;\n /** Header name for the signature. Default `X-Signature-256`. */\n signatureHeader?: string;\n /** Abort a request that takes longer than this (ms). Unset = no timeout. */\n timeoutMs?: number;\n /**\n * Redirect handling. Default `'follow'` (the `fetch` default). Use `'error'` when webhook URLs\n * are user-supplied, so a 3xx can't bounce the (possibly signed) request somewhere else.\n */\n redirect?: 'follow' | 'error' | 'manual';\n}\n\n/**\n * Implement this on a notification to define its webhook payload. Returning a plain\n * object is treated as the JSON body to POST; return a {@link WebhookMessage} for full\n * control over the url, method and headers.\n */\nexport interface WebhookNotification extends Notification {\n toWebhook(ctx: ChannelContext): WebhookMessage | Record<string, unknown>;\n}\n\n/** Resolves per-tenant {@link WebhookChannelOptions} from a tenant id. */\nexport type WebhookOptionsResolver = (tenant: string) => WebhookChannelOptions;\n\n/**\n * Delivers a notification by sending an HTTP request (JSON body) to a webhook endpoint.\n * The target URL comes from the {@link WebhookMessage}, else from\n * `routeNotificationFor('webhook')` (a URL string), else from the configured default.\n */\n@Injectable()\nexport class WebhookChannel extends BaseChannel {\n readonly channel = 'webhook';\n\n constructor(\n @Inject(WEBHOOK_OPTIONS)\n private readonly options: WebhookChannelOptions,\n @Optional()\n @Inject(WEBHOOK_OPTIONS_RESOLVER)\n private readonly resolveOptions?: WebhookOptionsResolver,\n ) {\n super();\n }\n\n async send(\n notifiable: Notifiable,\n notification: Notification,\n context?: DeliveryContext,\n ): Promise<void> {\n const options = this.forTenant(this.options, context, this.resolveOptions);\n const result = this.buildPayload<WebhookMessage | Record<string, unknown>>(\n notification,\n notifiable,\n 'toWebhook',\n context,\n );\n const message =\n result instanceof WebhookMessage ? result : new WebhookMessage().payload(result);\n const request = message.toRequest();\n\n const route = routeFor(notifiable, 'webhook', notification);\n const url = request.url ?? (typeof route === 'string' ? route : undefined) ?? options.url;\n\n if (!url) {\n throw new Error(\n 'The webhook channel needs a target URL. Set one on the WebhookMessage via .url(), ' +\n 'return one from routeNotificationFor(\"webhook\"), or set url in forRoot().',\n );\n }\n\n const headers: Record<string, string> = { ...options.headers, ...request.headers };\n if (options.secret) {\n const signature = createHmac('sha256', options.secret)\n .update(JSON.stringify(request.body))\n .digest('hex');\n headers[options.signatureHeader ?? DEFAULT_SIGNATURE_HEADER] = `sha256=${signature}`;\n }\n\n await postJson(url, request.body, {\n label: 'Webhook',\n method: request.method,\n headers,\n ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),\n ...(options.redirect !== undefined ? { redirect: options.redirect } : {}),\n });\n }\n}\n","/** DI token for the resolved {@link WebhookChannelOptions} (default url, headers). */\nexport const WEBHOOK_OPTIONS = Symbol('WEBHOOK_OPTIONS');\n\n/** DI token for the optional per-tenant {@link WebhookChannelOptions} resolver. */\nexport const WEBHOOK_OPTIONS_RESOLVER = Symbol('WEBHOOK_OPTIONS_RESOLVER');\n","import { defineChannelModule } from '@dudousxd/nestjs-notifications-core';\nimport { type DynamicModule, Module } from '@nestjs/common';\nimport { WEBHOOK_OPTIONS, WEBHOOK_OPTIONS_RESOLVER } from './tokens';\nimport {\n WebhookChannel,\n type WebhookChannelOptions,\n type WebhookOptionsResolver,\n} from './webhook.channel';\n\nexport interface WebhookChannelModuleOptions {\n /** Default URL used when neither the message nor the route supplies one. */\n url?: string;\n /** Default headers merged into every request. */\n headers?: Record<string, string>;\n /**\n * HMAC-SHA256 signing secret. When set, every request carries\n * `<signatureHeader>: sha256=<hex digest of the JSON body>`.\n */\n secret?: string;\n /** Header name for the signature. Default `X-Signature-256`. */\n signatureHeader?: string;\n /** Abort a request that takes longer than this (ms). Unset = no timeout. */\n timeoutMs?: number;\n /** Redirect handling: `'follow'` (default), `'error'` or `'manual'`. */\n redirect?: 'follow' | 'error' | 'manual';\n /**\n * Optional per-tenant options resolver. When a notification is delivered with a\n * `context.tenant`, the returned options are used instead of the defaults.\n */\n resolveOptions?: WebhookOptionsResolver;\n /** Register globally so the channel is discoverable app-wide. Default true. */\n global?: boolean;\n}\n\n/**\n * Registers the webhook channel.\n *\n * ```ts\n * WebhookChannelModule.forRoot({ url: 'https://example.com/hooks/notifications' });\n * ```\n */\n@Module({})\nexport class WebhookChannelModule {\n static forRoot(options: WebhookChannelModuleOptions = {}): DynamicModule {\n const webhookOptions: WebhookChannelOptions = {\n // Set each field only when provided (exactOptionalPropertyTypes); the channel resolves a\n // per-notifiable URL and default headers when these are absent.\n ...(options.url !== undefined ? { url: options.url } : {}),\n ...(options.headers !== undefined ? { headers: options.headers } : {}),\n ...(options.secret !== undefined ? { secret: options.secret } : {}),\n ...(options.signatureHeader !== undefined\n ? { signatureHeader: options.signatureHeader }\n : {}),\n ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),\n ...(options.redirect !== undefined ? { redirect: options.redirect } : {}),\n };\n\n return defineChannelModule({\n module: WebhookChannelModule,\n channel: WebhookChannel,\n optionsToken: WEBHOOK_OPTIONS,\n options: webhookOptions,\n resolver: { token: WEBHOOK_OPTIONS_RESOLVER, value: options.resolveOptions },\n ...(options.global !== undefined ? { global: options.global } : {}),\n });\n }\n}\n"],"mappings":"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","names":["WebhookMessage","_url","_method","_headers","_payload","url","u","payload","obj","header","key","value","headers","method","m","urlValue","methodValue","headerValues","payloadValue","toRequest","body","createHmac","BaseChannel","createChannel","postJson","routeFor","Inject","Injectable","Optional","WEBHOOK_OPTIONS","Symbol","WEBHOOK_OPTIONS_RESOLVER","DEFAULT_SIGNATURE_HEADER","Webhook","createChannel","WebhookChannel","BaseChannel","channel","options","resolveOptions","send","notifiable","notification","context","forTenant","result","buildPayload","message","WebhookMessage","payload","request","toRequest","route","routeFor","url","undefined","Error","headers","secret","signature","createHmac","update","JSON","stringify","body","digest","signatureHeader","postJson","label","method","timeoutMs","redirect","defineChannelModule","Module","WebhookChannelModule","forRoot","options","webhookOptions","url","undefined","headers","secret","signatureHeader","timeoutMs","redirect","defineChannelModule","module","channel","WebhookChannel","optionsToken","WEBHOOK_OPTIONS","resolver","token","WEBHOOK_OPTIONS_RESOLVER","value","resolveOptions","global"]}
1
+ {"version":3,"sources":["../src/webhook-message.ts","../src/webhook.channel.ts","../src/signing.ts","../src/tokens.ts","../src/webhook.module.ts"],"sourcesContent":["/** The resolved request description produced by {@link WebhookMessage.toRequest}. */\nexport interface WebhookRequest {\n /** Target URL, when set on the message; otherwise resolved by the channel. */\n url?: string | undefined;\n headers: Record<string, string>;\n /** The JSON body to POST (a plain object). */\n body: Record<string, unknown>;\n}\n\n/**\n * A fluent builder for a direct webhook request (always a `POST` with a JSON body).\n *\n * ```ts\n * new WebhookMessage()\n * .url('https://example.com/hooks/orders')\n * .header('X-App', 'shop')\n * .payload({ event: 'order.paid', id: 42 });\n * ```\n */\nexport class WebhookMessage {\n private _url?: string;\n private _headers: Record<string, string> = {};\n private _payload: Record<string, unknown> = {};\n\n /** Set the target URL (overrides the route and configured default). */\n url(u: string): this {\n this._url = u;\n return this;\n }\n\n /** Set the JSON body to send. */\n payload(obj: Record<string, unknown>): this {\n this._payload = obj;\n return this;\n }\n\n /** Set a single request header. */\n header(key: string, value: string): this {\n this._headers[key] = value;\n return this;\n }\n\n /** Merge multiple request headers. */\n headers(obj: Record<string, string>): this {\n this._headers = { ...this._headers, ...obj };\n return this;\n }\n\n get urlValue(): string | undefined {\n return this._url;\n }\n\n get headerValues(): Record<string, string> {\n return this._headers;\n }\n\n get payloadValue(): Record<string, unknown> {\n return this._payload;\n }\n\n /** Serialize to a {@link WebhookRequest}. */\n toRequest(): WebhookRequest {\n return { url: this._url, headers: { ...this._headers }, body: this._payload };\n }\n}\n\n/**\n * A webhook event for subscription delivery: `@nestjs/webhooks` fans it out to every endpoint of\n * the tenant subscribed to `type`, signs it (Standard Webhooks), retries and logs each delivery.\n * Return one from `toWebhook(ctx)` instead of a {@link WebhookMessage}.\n *\n * ```ts\n * toWebhook() { return new WebhookEvent('invoice.paid', { id: this.invoice.id }); }\n * ```\n */\nexport class WebhookEvent<D = unknown> {\n constructor(\n /** Full-stop delimited: `invoice.paid`. In `WebhooksModule`'s `eventTypes` when that is set. */\n readonly type: string,\n /** JSON-serializable data, sent as the body's `data`. */\n readonly data: D,\n ) {}\n}\n","import { createHash, randomUUID } from 'node:crypto';\nimport {\n BaseChannel,\n type ChannelContext,\n type DeliveryContext,\n type Notifiable,\n type Notification,\n createChannel,\n routeFor,\n} from '@dudousxd/nestjs-notifications-core';\nimport { Inject, Injectable, Optional } from '@nestjs/common';\nimport { OutboxInbox } from '@nestjs/outbox';\nimport {\n type WebhookMessage as DispatchedWebhookMessage,\n HttpWebhookTransport,\n type HttpWebhookTransportOptions,\n WebhookTransport,\n Webhooks,\n} from '@nestjs/webhooks';\nimport { sha256Signature, standardWebhookHeaders } from './signing';\nimport { WEBHOOK_OPTIONS, WEBHOOK_OPTIONS_RESOLVER, WEBHOOK_TRANSACTION } from './tokens';\nimport { WebhookEvent, WebhookMessage } from './webhook-message';\n\nconst DEFAULT_SIGNATURE_HEADER = 'X-Signature-256';\nconst DEFAULT_TIMEOUT_MS = 15_000;\n/** Tenants whose `HttpWebhookTransport` is kept (oldest built first out). */\nconst MAX_CACHED_TRANSPORTS = 1000;\n\n/**\n * The outbox inbox consumer under which subscription deliveries record the `webhook-id`s they\n * dispatched, so a retried notification doesn't dispatch the same event twice.\n */\nexport const WEBHOOK_DISPATCH_CONSUMER = 'aviary.notifications.webhook-dispatch';\n\n/** Channel handle: use as `@Webhook()` on a payload method, or as a token in `via()`. */\nexport const Webhook = createChannel('webhook');\n\n/** Opens a transaction for subscription delivery, e.g. `(work) => db.transaction(work)`. */\nexport type WebhookTransactionRunner = <T>(work: (tx: unknown) => Promise<T>) => Promise<T>;\n\n/** Resolved runtime options for direct-URL delivery. */\nexport interface WebhookChannelOptions {\n /** Default URL used when neither the message nor the route supplies one. */\n url?: string;\n /** Default headers merged into every request. */\n headers?: Record<string, string>;\n /** Signing secret. Unset: requests are not signed. */\n secret?: string;\n /**\n * `'sha256'` (default): `<signatureHeader>: sha256=<hex HMAC of the body>`.\n * `'standard'`: Standard Webhooks headers, verifiable with `@nestjs/webhooks`'\n * `@VerifyWebhook()` and a `scheme: 'standard'` receiver; `secret` is then `whsec_…`.\n */\n signing?: 'sha256' | 'standard';\n /** Header for the `'sha256'` signature. Default `X-Signature-256`. */\n signatureHeader?: string;\n /** Abort a request that takes longer than this (ms). Default 15000. */\n timeoutMs?: number;\n /**\n * The SSRF policy of `@nestjs/webhooks`' `HttpWebhookTransport` (https only, every resolved\n * address checked and pinned, redirects never followed): `allowHttp`, `allowPrivateNetworks`,\n * `allowedAddresses`, `ca`, `maxResponseSize`, `lookup`.\n */\n delivery?: HttpWebhookTransportOptions;\n /**\n * A `@nestjs/webhooks` transport instance to send through instead (a proxy, mTLS, or\n * `InMemoryWebhookTransport` in tests). Takes precedence over `delivery`.\n */\n transport?: WebhookTransport;\n}\n\n/**\n * Implement this on a notification to define its webhook payload: a plain object (the JSON body),\n * a {@link WebhookMessage} (url, headers, body), or a {@link WebhookEvent} for subscription\n * delivery through `@nestjs/webhooks`.\n */\nexport interface WebhookNotification extends Notification {\n toWebhook(ctx: ChannelContext): WebhookMessage | WebhookEvent | Record<string, unknown>;\n}\n\n/** Resolves per-tenant {@link WebhookChannelOptions} from a tenant id. */\nexport type WebhookOptionsResolver = (tenant: string) => WebhookChannelOptions;\n\n/** What a direct delivery returns (the channel response). */\nexport interface WebhookDeliveryResult {\n /** The `webhook-id` (Standard signing) or a generated id. */\n id: string;\n statusCode: number;\n}\n\n/**\n * What a subscription delivery returns when its `webhook-id` was already dispatched by an earlier\n * attempt of the same delivery: nothing was dispatched this time.\n */\nexport interface WebhookAlreadyDispatched {\n id: string;\n duplicate: true;\n}\n\n/**\n * Delivers a notification as a webhook, two ways:\n *\n * - **Direct**: a signed `POST` of the JSON body to one URL (the message's, else the\n * `routeNotificationFor('webhook')` URL, else the configured default), through `@nestjs/webhooks`'\n * SSRF-guarded `HttpWebhookTransport`. A non-2xx answer fails the delivery.\n * - **Subscriptions**: a {@link WebhookEvent} is handed to `Webhooks.dispatch()`, which fans it\n * out to the endpoints subscribed to its type (for the delivery's tenant), with retries and a\n * delivery log. Needs `WebhooksModule` + `OutboxModule` and the `transaction` option. A\n * delivery with an `idempotencyKey` (the outbox dispatcher's message id) records its\n * `webhook-id` in the outbox inbox in the same transaction as the dispatch, so a retry of it\n * dispatches nothing and returns {@link WebhookAlreadyDispatched}.\n */\n@Injectable()\nexport class WebhookChannel extends BaseChannel {\n readonly channel = 'webhook';\n /**\n * `HttpWebhookTransport`s by tenant (`''` without one), rebuilt only when that tenant's\n * `delivery` options change. Keyed by tenant rather than by the resolved options object, since a\n * resolver may return a fresh object on every call.\n */\n private readonly transports = new Map<\n string,\n { delivery: HttpWebhookTransportOptions | undefined; transport: WebhookTransport }\n >();\n\n constructor(\n @Inject(WEBHOOK_OPTIONS)\n private readonly options: WebhookChannelOptions,\n @Optional()\n @Inject(WEBHOOK_OPTIONS_RESOLVER)\n private readonly resolveOptions?: WebhookOptionsResolver,\n @Optional()\n @Inject(WEBHOOK_TRANSACTION)\n private readonly transaction?: WebhookTransactionRunner,\n @Optional()\n private readonly webhooks?: Webhooks,\n @Optional()\n private readonly inbox?: OutboxInbox,\n ) {\n super();\n }\n\n async send(\n notifiable: Notifiable,\n notification: Notification,\n context?: DeliveryContext,\n ): Promise<WebhookDeliveryResult | DispatchedWebhookMessage | WebhookAlreadyDispatched> {\n const result = this.buildPayload<WebhookMessage | WebhookEvent | Record<string, unknown>>(\n notification,\n notifiable,\n 'toWebhook',\n context,\n );\n if (result instanceof WebhookEvent) return this.dispatch(result, context);\n\n const options = this.forTenant(this.options, context, this.resolveOptions);\n const message =\n result instanceof WebhookMessage ? result : new WebhookMessage().payload(result);\n const request = message.toRequest();\n const route = routeFor(notifiable, 'webhook', notification);\n const url = request.url ?? (typeof route === 'string' ? route : undefined) ?? options.url;\n if (!url) {\n throw new Error(\n 'The webhook channel needs a target URL. Set one on the WebhookMessage via .url(), ' +\n 'return one from routeNotificationFor(\"webhook\"), or set url in forRoot().',\n );\n }\n\n const body = JSON.stringify(request.body);\n const id = context?.idempotencyKey ?? `msg_${randomUUID().replaceAll('-', '')}`;\n const headers: Record<string, string> = {\n 'content-type': 'application/json',\n ...lowerCase(options.headers),\n ...lowerCase(request.headers),\n };\n if (options.secret) {\n if (options.signing === 'standard') {\n Object.assign(headers, standardWebhookHeaders(options.secret, id, body));\n } else {\n headers[(options.signatureHeader ?? DEFAULT_SIGNATURE_HEADER).toLowerCase()] =\n sha256Signature(options.secret, body);\n }\n }\n\n const response = await this.transportFor(options, context?.tenant).send(\n {\n url,\n headers,\n body,\n endpointId: 'aviary',\n deliveryId: id,\n messageId: id,\n type: 'notification',\n },\n { signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS), attempt: 1 },\n );\n if (response.statusCode < 200 || response.statusCode >= 300) {\n throw new Error(`Webhook request to ${url} failed with status ${response.statusCode}.`);\n }\n return { id, statusCode: response.statusCode };\n }\n\n private async dispatch(\n event: WebhookEvent,\n context: DeliveryContext | undefined,\n ): Promise<DispatchedWebhookMessage | WebhookAlreadyDispatched> {\n const webhooks = this.webhooks;\n if (!webhooks) {\n throw new Error(\n 'A WebhookEvent needs WebhooksModule (and OutboxModule) from @nestjs/webhooks.',\n );\n }\n if (!this.transaction) {\n throw new Error(\n 'A WebhookEvent needs WebhookChannelModule.forRoot({ transaction: (work) => db.transaction(work) }): ' +\n '@nestjs/webhooks writes the event through a transaction.',\n );\n }\n // The outbox message id of this delivery: a retry derives the same webhook-id.\n const id = context?.idempotencyKey ? webhookId(context.idempotencyKey) : undefined;\n const inbox = this.inbox;\n const outcome = await this.transaction(async (tx) => {\n const dispatch = () =>\n webhooks.dispatch(tx, {\n type: event.type,\n data: event.data,\n tenant: context?.tenant ?? null,\n ...(id ? { id } : {}),\n });\n if (!id || !inbox) return { duplicate: false as const, result: await dispatch() };\n // The record commits with the dispatched message, so an attempt whose transaction\n // committed is the only one that dispatches: a retry finds the record and skips it,\n // instead of adding the webhook-id to the outbox again (a duplicate key while the first\n // message is pending).\n return inbox.processInTransaction(tx, WEBHOOK_DISPATCH_CONSUMER, id, dispatch);\n });\n if (outcome.duplicate) return { id: id as string, duplicate: true };\n webhooks.notify();\n return outcome.result;\n }\n\n private transportFor(\n options: WebhookChannelOptions,\n tenant: string | undefined,\n ): WebhookTransport {\n if (options.transport) return options.transport;\n const key = tenant && this.resolveOptions ? tenant : '';\n const cached = this.transports.get(key);\n if (cached && sameDelivery(cached.delivery, options.delivery)) return cached.transport;\n const transport = new HttpWebhookTransport(options.delivery);\n this.transports.delete(key);\n this.transports.set(key, { delivery: options.delivery, transport });\n while (this.transports.size > MAX_CACHED_TRANSPORTS) {\n this.transports.delete(this.transports.keys().next().value as string);\n }\n return transport;\n }\n}\n\nfunction lowerCase(headers: Record<string, string> | undefined): Record<string, string> {\n const out: Record<string, string> = {};\n for (const [name, value] of Object.entries(headers ?? {})) out[name.toLowerCase()] = value;\n return out;\n}\n\n/**\n * Whether two `delivery` option objects configure the same transport: the same object, or the\n * same keys with identical values (arrays compared element by element), so a resolver returning a\n * fresh literal with the same settings reuses the transport.\n */\nfunction sameDelivery(\n a: HttpWebhookTransportOptions | undefined,\n b: HttpWebhookTransportOptions | undefined,\n): boolean {\n if (a === b) return true;\n if (!a || !b) return false;\n const keys = Object.keys(a) as (keyof HttpWebhookTransportOptions)[];\n if (keys.length !== Object.keys(b).length) return false;\n return keys.every((key) => {\n const x = a[key];\n const y = b[key];\n if (Array.isArray(x) && Array.isArray(y)) {\n return x.length === y.length && x.every((item, i) => Object.is(item, y[i]));\n }\n return Object.is(x, y);\n });\n}\n\n/**\n * The `webhook-id` for an idempotency key. `webhook-id` allows letters, digits, `_` and `-`, at\n * most 128 characters: a key that fits is kept readable (`msg_<key>`); any other is hashed\n * (`msgh_<sha256 hex>`), so distinct keys never share an id. The two prefixes differ, so a kept key\n * can't collide with a hashed one.\n */\nexport function webhookId(key: string): string {\n const kept = `msg_${key}`;\n if (/^[A-Za-z0-9_-]+$/.test(key) && kept.length <= 128) return kept;\n return `msgh_${createHash('sha256').update(key).digest('hex')}`;\n}\n","import { createHmac } from 'node:crypto';\n\n/**\n * Standard Webhooks headers (`webhook-id`, `webhook-timestamp`, `webhook-signature: v1,…`) for\n * `body`, verifiable by a `@nestjs/webhooks` receiver with `scheme: 'standard'` (`@VerifyWebhook`).\n * `secret` is `whsec_` + base64 (the prefix is optional).\n */\nexport function standardWebhookHeaders(\n secret: string,\n id: string,\n body: string,\n now: Date = new Date(),\n): Record<string, string> {\n const timestamp = Math.floor(now.getTime() / 1000);\n const encoded = secret.startsWith('whsec_') ? secret.slice('whsec_'.length) : secret;\n const key = Buffer.from(encoded, 'base64');\n if (key.length < 24 || key.length > 64) {\n throw new TypeError(\n 'A Standard Webhooks secret is `whsec_` followed by base64 of 24 to 64 random bytes.',\n );\n }\n const signature = createHmac('sha256', key)\n .update(`${id}.${timestamp}.`)\n .update(body)\n .digest('base64');\n return {\n 'webhook-id': id,\n 'webhook-timestamp': String(timestamp),\n 'webhook-signature': `v1,${signature}`,\n };\n}\n\n/** The legacy `sha256=<hex HMAC of the body>` signature (GitHub-style). */\nexport function sha256Signature(secret: string, body: string): string {\n return `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`;\n}\n","/** DI token for the resolved {@link WebhookChannelOptions}. */\nexport const WEBHOOK_OPTIONS = Symbol('WEBHOOK_OPTIONS');\n\n/** DI token for the optional per-tenant {@link WebhookChannelOptions} resolver. */\nexport const WEBHOOK_OPTIONS_RESOLVER = Symbol('WEBHOOK_OPTIONS_RESOLVER');\n\n/** DI token for the transaction opener used by subscription delivery. */\nexport const WEBHOOK_TRANSACTION = Symbol('WEBHOOK_TRANSACTION');\n","import { defineChannelModule } from '@dudousxd/nestjs-notifications-core';\nimport { type DynamicModule, Module } from '@nestjs/common';\nimport { WEBHOOK_OPTIONS, WEBHOOK_OPTIONS_RESOLVER, WEBHOOK_TRANSACTION } from './tokens';\nimport {\n WebhookChannel,\n type WebhookChannelOptions,\n type WebhookOptionsResolver,\n type WebhookTransactionRunner,\n} from './webhook.channel';\n\nexport interface WebhookChannelModuleOptions extends WebhookChannelOptions {\n /**\n * Optional per-tenant options resolver. When a notification is delivered with a\n * `context.tenant`, the returned options are used instead of the defaults.\n */\n resolveOptions?: WebhookOptionsResolver;\n /**\n * Opens a transaction for subscription delivery ({@link WebhookEvent}): `@nestjs/webhooks`\n * writes the event to the outbox through it, e.g. `(work) => db.transaction(work)`.\n */\n transaction?: WebhookTransactionRunner;\n /** Register globally so the channel is discoverable app-wide. Default true. */\n global?: boolean;\n}\n\n/**\n * Registers the webhook channel.\n *\n * ```ts\n * WebhookChannelModule.forRoot({ url: 'https://example.com/hooks', secret: 'whsec_…', signing: 'standard' });\n * ```\n */\n@Module({})\nexport class WebhookChannelModule {\n static forRoot(options: WebhookChannelModuleOptions = {}): DynamicModule {\n const { resolveOptions, transaction, global, ...channelOptions } = options;\n const module = defineChannelModule({\n module: WebhookChannelModule,\n channel: WebhookChannel,\n optionsToken: WEBHOOK_OPTIONS,\n options: channelOptions,\n resolver: { token: WEBHOOK_OPTIONS_RESOLVER, value: resolveOptions },\n ...(global !== undefined ? { global } : {}),\n });\n return {\n ...module,\n providers: [\n ...(module.providers ?? []),\n { provide: WEBHOOK_TRANSACTION, useValue: transaction ?? null },\n ],\n };\n }\n}\n"],"mappings":"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","names":["WebhookMessage","_url","_headers","_payload","url","u","payload","obj","header","key","value","headers","urlValue","headerValues","payloadValue","toRequest","body","WebhookEvent","type","data","createHash","randomUUID","BaseChannel","createChannel","routeFor","Inject","Injectable","Optional","OutboxInbox","HttpWebhookTransport","Webhooks","createHmac","standardWebhookHeaders","secret","id","body","now","Date","timestamp","Math","floor","getTime","encoded","startsWith","slice","length","key","Buffer","from","TypeError","signature","createHmac","update","digest","String","sha256Signature","WEBHOOK_OPTIONS","Symbol","WEBHOOK_OPTIONS_RESOLVER","WEBHOOK_TRANSACTION","DEFAULT_SIGNATURE_HEADER","DEFAULT_TIMEOUT_MS","MAX_CACHED_TRANSPORTS","WEBHOOK_DISPATCH_CONSUMER","Webhook","createChannel","WebhookChannel","BaseChannel","channel","transports","Map","options","resolveOptions","transaction","webhooks","inbox","send","notifiable","notification","context","result","buildPayload","WebhookEvent","dispatch","forTenant","message","WebhookMessage","payload","request","toRequest","route","routeFor","url","undefined","Error","body","JSON","stringify","id","idempotencyKey","randomUUID","replaceAll","headers","lowerCase","secret","signing","Object","assign","standardWebhookHeaders","signatureHeader","toLowerCase","sha256Signature","response","transportFor","tenant","endpointId","deliveryId","messageId","type","signal","AbortSignal","timeout","timeoutMs","attempt","statusCode","event","webhookId","outcome","tx","data","duplicate","processInTransaction","notify","transport","key","cached","get","sameDelivery","delivery","HttpWebhookTransport","delete","set","size","keys","next","value","out","name","entries","a","b","length","every","x","y","Array","isArray","item","i","is","kept","test","createHash","update","digest","defineChannelModule","Module","WebhookChannelModule","forRoot","options","resolveOptions","transaction","global","channelOptions","module","defineChannelModule","channel","WebhookChannel","optionsToken","WEBHOOK_OPTIONS","resolver","token","WEBHOOK_OPTIONS_RESOLVER","value","undefined","providers","provide","WEBHOOK_TRANSACTION","useValue"]}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@dudousxd/nestjs-notifications-webhook",
3
- "version": "0.6.0",
4
- "description": "Webhook channel for nestjs-notifications — deliver notifications by POSTing JSON to an HTTP endpoint",
3
+ "version": "0.7.1",
4
+ "description": "Webhook channel for nestjs-notifications — direct signed POSTs over @nestjs/webhooks' SSRF-guarded transport, or subscription delivery through @nestjs/webhooks",
5
5
  "license": "MIT",
6
6
  "author": "Davide Carvalho",
7
7
  "repository": {
@@ -26,13 +26,21 @@
26
26
  ],
27
27
  "peerDependencies": {
28
28
  "@dudousxd/nestjs-notifications-core": ">=0.1.0 <1.0.0",
29
- "@nestjs/common": "^10.0.0 || ^11.0.0 || ^12.0.0"
29
+ "@nestjs/common": "^12.0.0",
30
+ "@nestjs/webhooks": ">=0.0.4 <0.1.0",
31
+ "@nestjs/outbox": ">=0.1.4 <0.2.0"
30
32
  },
31
33
  "devDependencies": {
32
- "@nestjs/common": "12.0.1",
34
+ "@nestjs/common": "12.3.2",
33
35
  "reflect-metadata": "0.2.2",
34
36
  "typescript": "5.9.3",
35
- "@dudousxd/nestjs-notifications-core": "^0.10.0"
37
+ "@nestjs/webhooks": "0.0.4",
38
+ "@nestjs/outbox": "0.1.4",
39
+ "@nestjs/core": "12.3.2",
40
+ "@nestjs/testing": "12.3.2",
41
+ "@nestjs/event-emitter": "12.0.1",
42
+ "rxjs": "7.8.2",
43
+ "@dudousxd/nestjs-notifications-core": "^0.11.1"
36
44
  },
37
45
  "module": "./dist/index.js",
38
46
  "exports": {
@@ -47,6 +55,11 @@
47
55
  }
48
56
  }
49
57
  },
58
+ "peerDependenciesMeta": {
59
+ "@nestjs/outbox": {
60
+ "optional": true
61
+ }
62
+ },
50
63
  "scripts": {
51
64
  "build": "tsup",
52
65
  "typecheck": "tsc -p tsconfig.json --noEmit"