@fluojs/discord 1.0.4 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.ko.md CHANGED
@@ -12,9 +12,13 @@ fluo를 위한 webhook-first, transport-agnostic Discord 전달 코어 패키지
12
12
  - [사용 시점](#사용-시점)
13
13
  - [빠른 시작](#빠른-시작)
14
14
  - [일반적인 패턴](#일반적인-패턴)
15
+ - [모듈 visibility와 migration 경계](#모듈-visibility와-migration-경계)
15
16
  - [`DiscordService`를 이용한 standalone 전달](#discordservice를-이용한-standalone-전달)
17
+ - [`verifyOnModuleInit` bootstrap verification](#verifyonmoduleinit-bootstrap-verification)
16
18
  - [`@fluojs/notifications`와의 통합](#fluojs-notifications와의-통합)
19
+ - [payload override를 사용하는 template rendering](#payload-override를-사용하는-template-rendering)
17
20
  - [명시적 fetch 주입을 사용하는 webhook-first 전달](#명시적-fetch-주입을-사용하는-webhook-first-전달)
21
+ - [구성 가능한 webhook 재시도 정책](#구성-가능한-webhook-재시도-정책)
18
22
  - [의도적인 제한 사항](#의도적인-제한-사항)
19
23
  - [공개 API 개요](#공개-api-개요)
20
24
  - [관련 패키지](#관련-패키지)
@@ -26,7 +30,7 @@ fluo를 위한 webhook-first, transport-agnostic Discord 전달 코어 패키지
26
30
  npm install @fluojs/discord @fluojs/notifications
27
31
  ```
28
32
 
29
- 이 패키지는 published package metadata에 반영된 저장소 전반의 Node.js 20+ 설치 baseline을 따르지만, 런타임 전달 계약 자체는 명시적인 fetch-compatible 경계를 통해 계속 transport-agnostic하게 유지됩니다.
33
+ 이 패키지는 패키지 자체의 지원 계약에 따라 Node.js `>=24.0.0 <27`을 요구합니다. Node 20 Node 22 host를 Node.js `>=24.0.0 <27`로 올리세요. Node 24 미만과 Node 27 이상은 지원하지 않으므로 process를 지원되는 line으로 옮겨야 합니다. 런타임 전달 계약 자체는 명시적인 fetch-compatible 경계를 통해 계속 transport-agnostic하게 유지됩니다.
30
34
 
31
35
  ## 사용 시점
32
36
 
@@ -77,6 +81,12 @@ export class DeployNotifier {
77
81
 
78
82
  ## 일반적인 패턴
79
83
 
84
+ ### 모듈 visibility와 migration 경계
85
+
86
+ `DiscordModule.forRoot(...)`와 `DiscordModule.forRootAsync(...)`는 기본적으로 global module을 반환합니다. 이 모듈은 `DiscordService`, `DiscordChannel`, `DISCORD`, `DISCORD_CHANNEL`을 export합니다. 반환된 모듈을 명시적으로 import한 모듈에서만 이 provider들을 보이게 해야 하는 migrated code가 있을 때만 `global: false`를 전달하세요. 이 옵션은 NestJS `isGlobal`이 아니라 `global?: boolean`입니다.
87
+
88
+ 패키지 수준 registration surface는 의도적으로 singleton 중심입니다. `DISCORD`와 `DISCORD_CHANNEL`은 하나의 구성된 Discord service와 notifications channel을 위한 compatibility token입니다. 여러 Discord client가 필요한 애플리케이션은 private provider helper를 import하지 말고 서로 다른 `DiscordTransport` 인스턴스를 감싼 app-owned module/provider 또는 app-owned facade를 구성해야 합니다.
89
+
80
90
  ### `DiscordService`를 이용한 standalone 전달
81
91
 
82
92
  notifications foundation을 거치지 않고 직접 Discord 전달을 하고 싶다면 `DiscordService`를 사용합니다.
@@ -101,13 +111,33 @@ Behavioral contract 메모:
101
111
  - `DiscordModule.forRoot(...)`와 `DiscordModule.forRootAsync(...)`는 `DiscordService`, `DiscordChannel`, `DISCORD`, `DISCORD_CHANNEL`을 기본 global로 export합니다. fluo 옵션인 `global?: boolean`을 사용하고, migrated code가 Discord provider를 importing module 안에만 유지해야 할 때만 `global: false`를 설정하세요. NestJS `isGlobal`은 지원하지 않습니다.
102
112
  - `DiscordService.send(...)`는 전달 전에 `defaultThreadId`를 해석합니다.
103
113
  - `DiscordService.sendMany(...)`는 `DiscordMessage[]`를 직접 순차 전송하는 batch API이며 `continueOnError`를 지원합니다. 이는 multi-recipient `@fluojs/notifications` dispatch shortcut이 아닙니다.
104
- - 서비스는 모듈 bootstrap 시 transport를 초기화하고, shutdown 전에 시작된 factory 생성 transport가 아직 완료되지 않았더라도 이를 기다린 factory 소유한 리소스를 애플리케이션 shutdown 닫습니다.
114
+ - 서비스는 모듈 bootstrap 시 transport를 초기화하고, bootstrap verification 실패와 애플리케이션 shutdown 전반에서 factory-owned 리소스를 정확히 한 번 닫습니다. shutdown 전에 시작된 factory 생성 transport가 아직 완료되지 않았더라도 이를 기다리며, 진행 중인 bootstrap verification이 settle된 뒤에만 factory-owned transport를 닫습니다. Verification 실패 후 owned cleanup도 실패하면 status diagnostics는 이를 `shutdown-cleanup`으로 재분류하지 않고 최초 `initialization` failure phase를 유지하며, reject된 factory creation도 initialization failure로 유지됩니다.
105
115
  - send는 bootstrap이 transport를 `ready`로 표시한 뒤에만 허용됩니다. bootstrap 전, startup 중, bootstrap 실패 후, shutdown 중, shutdown 후 시도는 전달 전에 거부됩니다.
116
+ - `ready` 상태에서 허용된 직접 send 또는 notification dispatch는 factory-owned transport resource를 닫기 전에 renderer와 transport delivery가 끝날 때까지 drain합니다. 전달 실패는 caller의 결과로 남고 shutdown 실패로 전파하지 않습니다.
106
117
  - 서비스가 shutdown 중이거나 이미 stopped 상태라면 cached transport를 재사용하지 않고 send를 거부합니다.
107
- - `DiscordService.createPlatformStatusSnapshot()`은 `createDiscordPlatformStatusSnapshot(...)`과 같은 status 계약을 노출합니다. 여기에는 lifecycle/readiness, health, transport kind와 ownership, 기본 thread 구성, bootstrap verification 상태, notifications channel dependency details가 포함되어, 호출자가 내부 옵션에 접근하지 않고도 Discord wiring을 관찰할 수 있습니다.
118
+ - `DiscordService.sendNotification(...)`은 구성된 renderer를 호출하기 전에 lifecycle readiness 확인하고, 호출자의 `AbortSignal`을 `DiscordTemplateRenderInput.signal`과 transport delivery 양쪽에 전달합니다.
119
+ - `DiscordService.createPlatformStatusSnapshot()`은 `createDiscordPlatformStatusSnapshot(...)`과 같은 status 계약을 노출합니다. 여기에는 lifecycle/readiness, health, transport kind와 ownership, 기본 thread 구성, bootstrap verification 상태, bootstrap initialization 실패와 shutdown cleanup 실패를 구분하는 diagnostics, notifications channel dependency details가 포함되어, 호출자가 내부 옵션에 접근하지 않고도 Discord wiring을 관찰할 수 있습니다.
108
120
  - 빈 `defaultThreadId`와 `notifications.channel` 값은 trim 후 무시됩니다. notifications channel은 기본적으로 `discord`입니다.
109
121
  - 이 패키지는 절대로 `process.env`를 직접 읽지 않습니다. 모든 설정은 명시적인 옵션 또는 DI를 통해 들어와야 합니다.
110
122
 
123
+ ### `verifyOnModuleInit` bootstrap verification
124
+
125
+ 선택한 transport가 애플리케이션 bootstrap 중 자체 readiness를 검증할 수 있다면 `DiscordModuleOptions.verifyOnModuleInit?: boolean`을 `true`로 설정하세요. `DiscordService.onModuleInit()`은 항상 구성된 transport를 먼저 해석합니다. `verifyOnModuleInit`이 켜져 있고 해석된 transport가 optional `verify()` 메서드를 노출하면, 서비스는 Discord provider를 ready로 표시하기 전에 `transport.verify()`를 await합니다. `verify`를 구현하지 않은 transport도 유효하며 verification 단계를 건너뜁니다.
126
+
127
+ ```typescript
128
+ DiscordModule.forRoot({
129
+ transport: customDiscordTransport,
130
+ verifyOnModuleInit: true,
131
+ });
132
+ ```
133
+
134
+ Behavioral contract 메모:
135
+
136
+ - `verifyOnModuleInit`은 optional이며 기본값은 `false`입니다.
137
+ - Verification은 capability 기반입니다. `verify()`를 노출한 transport만 호출하므로 webhook-only 또는 app-owned transport가 no-op verifier를 추가할 필요는 없습니다.
138
+ - `transport.verify()`가 reject하면 bootstrap은 initialization failure로 실패하고, service lifecycle은 `failed`로 이동하며, readiness/status snapshot은 provider를 not ready로 보고합니다. Factory-owned transport는 정확히 한 번 닫고, 직접 전달된 app-owned transport는 보존합니다.
139
+ - `DiscordService.createPlatformStatusSnapshot()`은 `verifiedOnModuleInit`과 bootstrap verification 상태를 포함하므로 health/readiness tooling이 내부 옵션에 접근하지 않고도 bootstrap verification 요청 여부를 확인할 수 있습니다.
140
+
111
141
  ### `@fluojs/notifications`와의 통합
112
142
 
113
143
  `DISCORD_CHANNEL`을 `NotificationsModule.forRootAsync(...)`에 주입하여, Discord 전용 payload 필드와 recipient-to-thread 해석 규칙이 모두 `@fluojs/discord` 안에만 남도록 구성합니다.
@@ -150,9 +180,54 @@ Behavioral contract 메모:
150
180
 
151
181
  - 하나의 notification dispatch는 정확히 하나의 Discord thread 경로로 매핑됩니다. `payload.threadId` 또는 `recipients`의 단일 항목을 사용해야 합니다.
152
182
  - `payload.threadId`가 없으면 `DiscordService.sendNotification(...)`는 첫 번째 `recipients` 항목을 사용하고, 그것도 없으면 `defaultThreadId`로 폴백합니다.
153
- - notification metadata는 payload metadata, dispatch metadata, template/subject marker를 합쳐 구성됩니다. `template`은 renderer가 구성된 경우에만 렌더링됩니다.
183
+ - notification metadata는 payload metadata, dispatch metadata, template/subject marker를 합쳐 구성됩니다. 중복 key에서는 dispatch metadata가 payload metadata를 덮어쓰고, 최종 `subject` / `template` marker가 둘 모두를 덮어씁니다. `template`은 renderer가 구성된 경우에만 렌더링됩니다.
184
+ - Template rendering은 서비스가 ready일 때만 시작합니다. Render input에는 `signal`이 포함되므로 renderer는 transport delivery 전에 caller-cancelled 작업을 중단할 수 있습니다.
154
185
  - 여러 Discord thread로 fan-out이 필요한 notification workflow라면 thread별 concrete Discord message를 만들어 `DiscordService.sendMany(...)`로 보내거나 별도 notification dispatch를 실행해야 합니다. 하나의 notification dispatch는 multi-recipient fan-out을 암묵적으로 확장하지 않습니다.
155
186
 
187
+ ### payload override를 사용하는 template rendering
188
+
189
+ Notification template에서 재사용 가능한 Discord content, embed, component를 생성하려면 `DiscordTemplateRenderer`를 등록합니다. 같은 module registration에 transport를 설정하고 `@fluojs/notifications`를 통해 `template` key를 dispatch하세요.
190
+
191
+ ```typescript
192
+ import type { DiscordTemplateRenderer } from '@fluojs/discord';
193
+
194
+ const renderer: DiscordTemplateRenderer = {
195
+ render(input) {
196
+ return {
197
+ content: `Order ${String(input.payload.orderId)} was received.`,
198
+ embeds: [
199
+ {
200
+ description: input.subject,
201
+ title: 'New order',
202
+ },
203
+ ],
204
+ };
205
+ },
206
+ };
207
+
208
+ DiscordModule.forRoot({
209
+ renderer,
210
+ transport: createDiscordWebhookTransport({
211
+ fetch: runtime.fetch,
212
+ webhookUrl: config.discordWebhookUrl,
213
+ }),
214
+ });
215
+
216
+ await notifications.dispatch({
217
+ channel: 'discord',
218
+ locale: 'en',
219
+ metadata: { source: 'orders' },
220
+ payload: {
221
+ content: 'Order #123 is ready for review.',
222
+ orderId: '123',
223
+ },
224
+ subject: 'New order received',
225
+ template: 'orders.received',
226
+ });
227
+ ```
228
+
229
+ `DiscordService.sendNotification(...)`은 `template`과 `renderer`가 모두 있을 때만 renderer를 호출합니다. Renderer는 `{ template, payload, subject, locale, metadata, signal }`을 받습니다. 명시적인 `payload.content`, `payload.embeds`, `payload.components` 값은 대응하는 rendered 값보다 우선합니다. `payload.content`가 `undefined`이면 rendered content, `subject` 순서로 fallback합니다. 따라서 renderer나 transport 설정을 교체하지 않고도 호출자가 template 결과의 일부를 override할 수 있습니다.
230
+
156
231
  ### 명시적 fetch 주입을 사용하는 webhook-first 전달
157
232
 
158
233
  런타임에 독립적인 1st-party transport가 필요하다면 fetch-compatible HTTP 경계만 의존하는 `createDiscordWebhookTransport(...)`를 사용합니다.
@@ -171,9 +246,27 @@ await discord.send({
171
246
 
172
247
  bot 기반 REST 전달처럼 더 풍부한 API 연동이 필요하다면 export된 `DiscordTransport` 계약을 구현해 `DiscordModule.forRoot(...)` 또는 `forRootAsync(...)`에 주입하면 됩니다.
173
248
 
249
+ ### 구성 가능한 webhook 재시도 정책
250
+
251
+ 내장 webhook transport는 일시적인 `408`, `429`, `5xx` 응답과 transport-level exception을 재시도합니다. `retry`를 생략하면 총 세 번 시도하고 250ms를 base로 지수 backoff하는 기존 기본값을 유지합니다. workload의 latency 또는 허용 가능한 재시도 수준이 다르다면 transport 생성 시 하나의 정책을 구성하세요.
252
+
253
+ ```typescript
254
+ const transport = createDiscordWebhookTransport({
255
+ fetch: runtime.fetch,
256
+ retry: {
257
+ attempts: 5,
258
+ baseDelayMs: 500,
259
+ },
260
+ webhookUrl: config.discordWebhookUrl,
261
+ });
262
+ ```
263
+
264
+ `attempts`에는 최초 요청이 포함되며 `1`부터 `10`까지의 정수여야 합니다. `baseDelayMs`는 `0`부터 `60000`까지의 정수여야 하고 이후 대기 시간은 이 값을 기준으로 두 배씩 증가합니다. 이 정책은 이 프로세스 안에서 한 번 보내는 bounded webhook 재시도에만 적용됩니다. 프로세스 재시작 뒤에도 notification을 보존해야 하거나, durable scheduling이 필요하거나, request latency와 독립적으로 delivery를 관리해야 한다면 webhook attempts를 늘리는 대신 queue-backed delivery를 사용하는 `@fluojs/notifications`를 사용하세요.
265
+
174
266
  Behavioral contract 메모:
175
267
 
176
- - 내장 webhook transport는 `408`, `429`, `5xx` 같은 일시적 응답뿐 아니라 transport-level exception도 bounded exponential backoff로 재시도한 뒤 호출자에게 에러를 노출합니다. 영구적인 upstream 응답은 재시도하지 않습니다.
268
+ - 내장 webhook transport는 `408`, `429`, `5xx` 같은 일시적 응답뿐 아니라 transport-level exception도 구성한 bounded exponential backoff로 재시도한 뒤 호출자에게 에러를 노출합니다. 영구적인 upstream 응답은 재시도하지 않습니다.
269
+ - Retry backoff는 `DiscordSendOptions.signal`을 관찰합니다. 이미 abort된 signal은 다음 backoff timer를 기다리지 않고 즉시 reject됩니다.
177
270
  - 성공한 webhook 응답은 `DiscordSendResult.response`로 노출됩니다. rate-limit 재시도가 끝내 실패한 경우를 포함해, 호출자에게 보이는 `DiscordTransportError` 메시지는 기본적으로 raw upstream response body를 포함하지 않습니다.
178
271
  - 잘못되었거나 절대 URL이 아닌 `webhookUrl` 값은 전달 실패로 재시도하지 않고 즉시 `DiscordConfigurationError`로 거부됩니다.
179
272
 
@@ -214,6 +307,7 @@ Discord 패키지는 의도적으로 다음을 **포함하지 않습니다**:
214
307
 
215
308
  - `DiscordMessage`
216
309
  - `NormalizedDiscordMessage`
310
+ - `DiscordWebhookRetryOptions`
217
311
  - `DiscordWebhookTransportOptions`
218
312
  - `DiscordFetchLike`
219
313
  - `DiscordFetchResponse`
package/README.md CHANGED
@@ -12,9 +12,13 @@ Migration boundary: the module API is intentionally Nest-like but not a NestJS d
12
12
  - [When to Use](#when-to-use)
13
13
  - [Quick Start](#quick-start)
14
14
  - [Common Patterns](#common-patterns)
15
+ - [Module visibility and migration boundaries](#module-visibility-and-migration-boundaries)
15
16
  - [Standalone delivery with `DiscordService`](#standalone-delivery-with-discordservice)
17
+ - [Bootstrap verification with `verifyOnModuleInit`](#bootstrap-verification-with-verifyonmoduleinit)
16
18
  - [Integration with `@fluojs/notifications`](#integration-with-fluojs-notifications)
19
+ - [Template rendering with payload overrides](#template-rendering-with-payload-overrides)
17
20
  - [Webhook-first delivery with explicit fetch injection](#webhook-first-delivery-with-explicit-fetch-injection)
21
+ - [Configurable webhook retry policy](#configurable-webhook-retry-policy)
18
22
  - [Intentional limitations](#intentional-limitations)
19
23
  - [Public API Overview](#public-api-overview)
20
24
  - [Related Packages](#related-packages)
@@ -26,7 +30,7 @@ Migration boundary: the module API is intentionally Nest-like but not a NestJS d
26
30
  npm install @fluojs/discord @fluojs/notifications
27
31
  ```
28
32
 
29
- This package follows the repo-wide Node.js 20+ install baseline reflected in published package metadata, while keeping its delivery contract transport-agnostic at runtime through explicit fetch-compatible boundaries.
33
+ This package requires Node.js `>=24.0.0 <27` as its package-owned support contract. Upgrade Node 20 and Node 22 hosts to Node.js `>=24.0.0 <27`; Node versions below 24 and Node 27+ are unsupported, so move the process to a supported line. Its delivery contract remains transport-agnostic at runtime through explicit fetch-compatible boundaries.
30
34
 
31
35
  ## When to Use
32
36
 
@@ -77,6 +81,12 @@ export class DeployNotifier {
77
81
 
78
82
  ## Common Patterns
79
83
 
84
+ ### Module visibility and migration boundaries
85
+
86
+ `DiscordModule.forRoot(...)` and `DiscordModule.forRootAsync(...)` return a global module by default. The module exports `DiscordService`, `DiscordChannel`, `DISCORD`, and `DISCORD_CHANNEL`; pass `global: false` only when migrated code needs those providers to remain visible only to modules that explicitly import the returned module. The option is `global?: boolean`, not NestJS `isGlobal`.
87
+
88
+ The package-level registration surface is intentionally singleton-oriented. `DISCORD` and `DISCORD_CHANNEL` are compatibility tokens for the one configured Discord service and notifications channel. Applications that need multiple Discord clients should compose app-owned modules/providers around distinct `DiscordTransport` instances or expose app-owned facades instead of importing private provider helpers.
89
+
80
90
  ### Standalone delivery with `DiscordService`
81
91
 
82
92
  Use `DiscordService` when your application wants direct Discord delivery without routing through the notifications foundation.
@@ -101,13 +111,33 @@ Behavioral contract notes:
101
111
  - `DiscordModule.forRoot(...)` and `DiscordModule.forRootAsync(...)` export `DiscordService`, `DiscordChannel`, `DISCORD`, and `DISCORD_CHANNEL` globally by default. Use the fluo `global?: boolean` option and set `global: false` only when migrated code must keep Discord providers local to importing modules; NestJS `isGlobal` is not supported.
102
112
  - `DiscordService.send(...)` resolves `defaultThreadId` before delivery.
103
113
  - `DiscordService.sendMany(...)` is a direct `DiscordMessage[]` batch API that sends messages sequentially and supports `continueOnError`; it is not a multi-recipient `@fluojs/notifications` dispatch shortcut.
104
- - The service initializes the configured transport during module bootstrap and closes factory-owned resources during application shutdown, including any in-flight factory-created transport before shutdown began.
114
+ - The service initializes the configured transport during module bootstrap and closes factory-owned resources exactly once across bootstrap verification failure and application shutdown, including any in-flight factory-created transport before shutdown began. Shutdown waits for in-flight bootstrap verification to settle before closing a factory-owned transport. If verification fails and the subsequent owned cleanup also fails, status diagnostics preserve the first `initialization` failure phase instead of reclassifying it as `shutdown-cleanup`; a rejected factory creation likewise remains an initialization failure.
105
115
  - Sends are accepted only after bootstrap marks the transport `ready`; attempts before bootstrap, during startup, after failed bootstrap, while shutting down, or after shutdown are rejected before delivery.
116
+ - A direct send or notification dispatch accepted while `ready` drains its renderer and transport delivery before factory-owned transport resources close. Delivery rejection remains the caller's result and does not make shutdown fail.
106
117
  - Sends attempted while the service is shutting down or already stopped are rejected before reusing the cached transport.
107
- - `DiscordService.createPlatformStatusSnapshot()` exposes the same status contract as `createDiscordPlatformStatusSnapshot(...)`: lifecycle/readiness, health, transport kind and ownership, default thread configuration, bootstrap verification state, and notifications channel dependency details, so callers can observe Discord wiring without reaching into internal options.
118
+ - `DiscordService.sendNotification(...)` checks lifecycle readiness before invoking a configured renderer and passes the caller's `AbortSignal` to both `DiscordTemplateRenderInput.signal` and transport delivery.
119
+ - `DiscordService.createPlatformStatusSnapshot()` exposes the same status contract as `createDiscordPlatformStatusSnapshot(...)`: lifecycle/readiness, health, transport kind and ownership, default thread configuration, bootstrap verification state, distinct bootstrap initialization versus shutdown cleanup failure diagnostics, and notifications channel dependency details, so callers can observe Discord wiring without reaching into internal options.
108
120
  - Blank `defaultThreadId` and `notifications.channel` values are trimmed and ignored; the notifications channel defaults to `discord`.
109
121
  - The package never reads `process.env` directly. All configuration must enter through explicit options or DI.
110
122
 
123
+ ### Bootstrap verification with `verifyOnModuleInit`
124
+
125
+ Set `DiscordModuleOptions.verifyOnModuleInit?: boolean` to `true` when the selected transport can verify its own readiness during application bootstrap. `DiscordService.onModuleInit()` always resolves the configured transport first; if `verifyOnModuleInit` is enabled **and** the resolved transport exposes an optional `verify()` method, the service awaits `transport.verify()` before marking the Discord provider ready. Transports that do not implement `verify` are still valid and simply skip the verification step.
126
+
127
+ ```typescript
128
+ DiscordModule.forRoot({
129
+ transport: customDiscordTransport,
130
+ verifyOnModuleInit: true,
131
+ });
132
+ ```
133
+
134
+ Behavioral contract notes:
135
+
136
+ - `verifyOnModuleInit` is optional and defaults to `false`.
137
+ - Verification is capability-based: only transports that expose `verify()` are called, so webhook-only or app-owned transports do not have to add a no-op verifier.
138
+ - If `transport.verify()` rejects, bootstrap fails with the initialization failure, the service lifecycle moves to `failed`, and readiness/status snapshots report the provider as not ready. Factory-owned transports are closed exactly once; directly supplied app-owned transports are preserved.
139
+ - `DiscordService.createPlatformStatusSnapshot()` includes `verifiedOnModuleInit` and bootstrap verification state so health/readiness tooling can tell whether bootstrap verification was requested without reaching into internal options.
140
+
111
141
  ### Integration with `@fluojs/notifications`
112
142
 
113
143
  Inject `DISCORD_CHANNEL` into `NotificationsModule.forRootAsync(...)` so the Discord package remains the only place that understands Discord-specific payload fields and recipient-to-thread translation.
@@ -150,9 +180,54 @@ Behavioral contract notes:
150
180
 
151
181
  - One notification dispatch maps to exactly one Discord thread route. Use `payload.threadId` or a single entry in `recipients`.
152
182
  - If `payload.threadId` is omitted, `DiscordService.sendNotification(...)` uses the first `recipients` entry or falls back to `defaultThreadId`.
153
- - Notification metadata is merged from payload metadata, dispatch metadata, and template/subject markers. `template` is rendered only when a renderer is configured.
183
+ - Notification metadata is merged from payload metadata, dispatch metadata, and template/subject markers. On duplicate keys, dispatch metadata overrides payload metadata, and final `subject` / `template` markers override both. `template` is rendered only when a renderer is configured.
184
+ - Template rendering starts only while the service is ready. The render input includes `signal`, so renderers can stop caller-cancelled work before transport delivery.
154
185
  - If a notification workflow needs fan-out across multiple Discord threads, create one concrete Discord message per thread with `DiscordService.sendMany(...)` or issue separate notification dispatches; a single notification dispatch never expands multi-recipient fan-out implicitly.
155
186
 
187
+ ### Template rendering with payload overrides
188
+
189
+ Register a `DiscordTemplateRenderer` when notification templates should produce reusable Discord content, embeds, or components. Keep transport setup in the same module registration and dispatch a `template` key through `@fluojs/notifications`.
190
+
191
+ ```typescript
192
+ import type { DiscordTemplateRenderer } from '@fluojs/discord';
193
+
194
+ const renderer: DiscordTemplateRenderer = {
195
+ render(input) {
196
+ return {
197
+ content: `Order ${String(input.payload.orderId)} was received.`,
198
+ embeds: [
199
+ {
200
+ description: input.subject,
201
+ title: 'New order',
202
+ },
203
+ ],
204
+ };
205
+ },
206
+ };
207
+
208
+ DiscordModule.forRoot({
209
+ renderer,
210
+ transport: createDiscordWebhookTransport({
211
+ fetch: runtime.fetch,
212
+ webhookUrl: config.discordWebhookUrl,
213
+ }),
214
+ });
215
+
216
+ await notifications.dispatch({
217
+ channel: 'discord',
218
+ locale: 'en',
219
+ metadata: { source: 'orders' },
220
+ payload: {
221
+ content: 'Order #123 is ready for review.',
222
+ orderId: '123',
223
+ },
224
+ subject: 'New order received',
225
+ template: 'orders.received',
226
+ });
227
+ ```
228
+
229
+ `DiscordService.sendNotification(...)` calls the renderer only when both `template` and `renderer` are present. The renderer receives `{ template, payload, subject, locale, metadata, signal }`. Explicit `payload.content`, `payload.embeds`, and `payload.components` values take precedence over the corresponding rendered values; when `payload.content` is `undefined`, content falls back to rendered content and then to `subject`. This lets callers override one template result without replacing the renderer or transport configuration.
230
+
156
231
  ### Webhook-first delivery with explicit fetch injection
157
232
 
158
233
  Use `createDiscordWebhookTransport(...)` when you want a portable first-party transport that only depends on a fetch-compatible HTTP boundary.
@@ -171,9 +246,27 @@ await discord.send({
171
246
 
172
247
  For richer API integrations such as bot-backed REST delivery, implement the exported `DiscordTransport` contract and inject it through `DiscordModule.forRoot(...)` or `forRootAsync(...)`.
173
248
 
249
+ ### Configurable webhook retry policy
250
+
251
+ The built-in webhook transport retries transient `408`, `429`, and `5xx` responses and transport-level exceptions. Omit `retry` to preserve the default of three total attempts with a 250ms exponential-backoff base delay. Configure one workload-specific policy at transport construction when its latency or tolerance differs:
252
+
253
+ ```typescript
254
+ const transport = createDiscordWebhookTransport({
255
+ fetch: runtime.fetch,
256
+ retry: {
257
+ attempts: 5,
258
+ baseDelayMs: 500,
259
+ },
260
+ webhookUrl: config.discordWebhookUrl,
261
+ });
262
+ ```
263
+
264
+ `attempts` includes the initial request and must be an integer from `1` through `10`. `baseDelayMs` must be an integer from `0` through `60000`; later waits double from that base. The retry policy applies only to this bounded in-process webhook send. When a notification must survive process restarts, require durable scheduling, or need delivery managed independently of request latency, use `@fluojs/notifications` with queue-backed delivery instead of increasing webhook attempts.
265
+
174
266
  Behavioral contract notes:
175
267
 
176
- - The built-in webhook transport retries transient `408`, `429`, and `5xx` responses, and also retries transport-level exceptions, using bounded exponential backoff before surfacing an error. Permanent upstream responses are not retried.
268
+ - The built-in webhook transport retries transient `408`, `429`, and `5xx` responses, and also retries transport-level exceptions, using the configured bounded exponential backoff before surfacing an error. Permanent upstream responses are not retried.
269
+ - Retry backoff observes `DiscordSendOptions.signal`; an already-aborted signal rejects immediately instead of waiting for the next backoff timer.
177
270
  - Successful webhook responses are exposed through `DiscordSendResult.response`; caller-visible `DiscordTransportError` messages still omit raw upstream response bodies by default, including after rate-limit retries fail.
178
271
  - Malformed or non-absolute `webhookUrl` values are rejected immediately as `DiscordConfigurationError` instead of being retried as delivery failures.
179
272
 
@@ -214,6 +307,7 @@ The package intentionally keeps `createDiscordProviders(...)`, `DISCORD_OPTIONS`
214
307
 
215
308
  - `DiscordMessage`
216
309
  - `NormalizedDiscordMessage`
310
+ - `DiscordWebhookRetryOptions`
217
311
  - `DiscordWebhookTransportOptions`
218
312
  - `DiscordFetchLike`
219
313
  - `DiscordFetchResponse`
package/dist/index.d.ts CHANGED
@@ -5,6 +5,6 @@ export { DiscordService } from './service.js';
5
5
  export { createDiscordPlatformStatusSnapshot } from './status.js';
6
6
  export type { DiscordLifecycleState, DiscordPlatformStatusSnapshot, DiscordStatusAdapterInput } from './status.js';
7
7
  export { DISCORD, DISCORD_CHANNEL } from './tokens.js';
8
- export type { Discord, DiscordAllowedMentions, DiscordAsyncModuleOptions, DiscordAttachment, DiscordComponent, DiscordEmbed, DiscordFetchLike, DiscordFetchResponse, DiscordMessage, DiscordModuleOptions, DiscordNotificationDispatchRequest, DiscordNotificationPayload, DiscordPoll, DiscordSendBatchResult, DiscordSendFailure, DiscordSendManyOptions, DiscordSendOptions, DiscordSendResult, DiscordTemplateRenderInput, DiscordTemplateRenderer, DiscordTemplateRenderResult, DiscordTransport, DiscordTransportContext, DiscordTransportFactory, DiscordTransportReceipt, DiscordWebhookTransportOptions, NormalizedDiscordMessage, } from './types.js';
8
+ export type { Discord, DiscordAllowedMentions, DiscordAsyncModuleOptions, DiscordAttachment, DiscordComponent, DiscordEmbed, DiscordFetchLike, DiscordFetchResponse, DiscordMessage, DiscordModuleOptions, DiscordNotificationDispatchRequest, DiscordNotificationPayload, DiscordPoll, DiscordSendBatchResult, DiscordSendFailure, DiscordSendManyOptions, DiscordSendOptions, DiscordSendResult, DiscordTemplateRenderInput, DiscordTemplateRenderer, DiscordTemplateRenderResult, DiscordTransport, DiscordTransportContext, DiscordTransportFactory, DiscordTransportReceipt, DiscordWebhookRetryOptions, DiscordWebhookTransportOptions, NormalizedDiscordMessage, } from './types.js';
9
9
  export { createDiscordWebhookTransport } from './webhook.js';
10
10
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,yBAAyB,EACzB,6BAA6B,EAC7B,qBAAqB,GACtB,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,EAAE,mCAAmC,EAAE,MAAM,aAAa,CAAC;AAClE,YAAY,EAAE,qBAAqB,EAAE,6BAA6B,EAAE,yBAAyB,EAAE,MAAM,aAAa,CAAC;AACnH,OAAO,EAAE,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACvD,YAAY,EACV,OAAO,EACP,sBAAsB,EACtB,yBAAyB,EACzB,iBAAiB,EACjB,gBAAgB,EAChB,YAAY,EACZ,gBAAgB,EAChB,oBAAoB,EACpB,cAAc,EACd,oBAAoB,EACpB,kCAAkC,EAClC,0BAA0B,EAC1B,WAAW,EACX,sBAAsB,EACtB,kBAAkB,EAClB,sBAAsB,EACtB,kBAAkB,EAClB,iBAAiB,EACjB,0BAA0B,EAC1B,uBAAuB,EACvB,2BAA2B,EAC3B,gBAAgB,EAChB,uBAAuB,EACvB,uBAAuB,EACvB,uBAAuB,EACvB,8BAA8B,EAC9B,wBAAwB,GACzB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,6BAA6B,EAAE,MAAM,cAAc,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,yBAAyB,EACzB,6BAA6B,EAC7B,qBAAqB,GACtB,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,EAAE,mCAAmC,EAAE,MAAM,aAAa,CAAC;AAClE,YAAY,EAAE,qBAAqB,EAAE,6BAA6B,EAAE,yBAAyB,EAAE,MAAM,aAAa,CAAC;AACnH,OAAO,EAAE,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACvD,YAAY,EACV,OAAO,EACP,sBAAsB,EACtB,yBAAyB,EACzB,iBAAiB,EACjB,gBAAgB,EAChB,YAAY,EACZ,gBAAgB,EAChB,oBAAoB,EACpB,cAAc,EACd,oBAAoB,EACpB,kCAAkC,EAClC,0BAA0B,EAC1B,WAAW,EACX,sBAAsB,EACtB,kBAAkB,EAClB,sBAAsB,EACtB,kBAAkB,EAClB,iBAAiB,EACjB,0BAA0B,EAC1B,uBAAuB,EACvB,2BAA2B,EAC3B,gBAAgB,EAChB,uBAAuB,EACvB,uBAAuB,EACvB,uBAAuB,EACvB,0BAA0B,EAC1B,8BAA8B,EAC9B,wBAAwB,GACzB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,6BAA6B,EAAE,MAAM,cAAc,CAAC"}
package/dist/module.d.ts CHANGED
@@ -6,7 +6,7 @@ export declare class DiscordModule {
6
6
  * Registers Discord providers using static options.
7
7
  *
8
8
  * @param options Static Discord module options including transport wiring and optional template rendering behavior.
9
- * @returns A global module definition that exports {@link DiscordService}, {@link DiscordChannel}, and compatibility tokens.
9
+ * @returns A module definition that exports {@link DiscordService}, {@link DiscordChannel}, and compatibility tokens, globally by default unless `global` is `false`.
10
10
  *
11
11
  * @example
12
12
  * ```ts
@@ -20,7 +20,7 @@ export declare class DiscordModule {
20
20
  * Registers Discord providers from an async DI factory.
21
21
  *
22
22
  * @param options Async module options that resolve Discord transport and renderer configuration through DI.
23
- * @returns A global module definition that memoizes async option resolution per module instance.
23
+ * @returns A module definition that memoizes async option resolution per module instance and is global by default unless `global` is `false`.
24
24
  *
25
25
  * @example
26
26
  * ```ts
package/dist/module.js CHANGED
@@ -94,7 +94,7 @@ export class DiscordModule {
94
94
  * Registers Discord providers using static options.
95
95
  *
96
96
  * @param options Static Discord module options including transport wiring and optional template rendering behavior.
97
- * @returns A global module definition that exports {@link DiscordService}, {@link DiscordChannel}, and compatibility tokens.
97
+ * @returns A module definition that exports {@link DiscordService}, {@link DiscordChannel}, and compatibility tokens, globally by default unless `global` is `false`.
98
98
  *
99
99
  * @example
100
100
  * ```ts
@@ -111,7 +111,7 @@ export class DiscordModule {
111
111
  * Registers Discord providers from an async DI factory.
112
112
  *
113
113
  * @param options Async module options that resolve Discord transport and renderer configuration through DI.
114
- * @returns A global module definition that memoizes async option resolution per module instance.
114
+ * @returns A module definition that memoizes async option resolution per module instance and is global by default unless `global` is `false`.
115
115
  *
116
116
  * @example
117
117
  * ```ts
package/dist/service.d.ts CHANGED
@@ -10,13 +10,20 @@ import type { Discord, DiscordMessage, DiscordNotificationDispatchRequest, Disco
10
10
  */
11
11
  export declare class DiscordService implements Discord, OnModuleInit, OnApplicationShutdown {
12
12
  private readonly options;
13
+ private readonly acceptedDeliveryPromises;
14
+ private lifecycleFailurePhase;
13
15
  private lifecycleState;
16
+ private ownedTransportCleanupPromise;
14
17
  private resolvedTransport;
15
18
  private shutdownPromise;
16
19
  private transportPromise;
20
+ private transportVerificationPromise;
17
21
  constructor(options: NormalizedDiscordModuleOptions);
18
22
  onApplicationShutdown(): Promise<void>;
19
23
  private closeOwnedTransport;
24
+ private closeOwnedTransportResources;
25
+ private closeOwnedTransportResourcesOnce;
26
+ private resolveTransportForCleanup;
20
27
  onModuleInit(): Promise<void>;
21
28
  /**
22
29
  * Creates a platform status snapshot for the active Discord transport wiring.
@@ -40,6 +47,7 @@ export declare class DiscordService implements Discord, OnModuleInit, OnApplicat
40
47
  * ```
41
48
  */
42
49
  send(message: DiscordMessage, options?: DiscordSendOptions): Promise<DiscordSendResult>;
50
+ private sendAccepted;
43
51
  /**
44
52
  * Sends multiple Discord messages in input order with optional tolerant failure handling.
45
53
  *
@@ -72,7 +80,12 @@ export declare class DiscordService implements Discord, OnModuleInit, OnApplicat
72
80
  * ```
73
81
  */
74
82
  sendNotification(notification: DiscordNotificationDispatchRequest, options?: DiscordSendOptions): Promise<DiscordSendResult>;
83
+ private sendNotificationAccepted;
75
84
  private ensureTransport;
85
+ private clearResolvedTransport;
86
+ private drainAcceptedDeliveries;
87
+ private trackAcceptedDelivery;
88
+ private handleTransportInitializationFailure;
76
89
  private assertReadyForSend;
77
90
  private normalizeMessage;
78
91
  private resolveNotificationThreadId;
@@ -1 +1 @@
1
- {"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,qBAAqB,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAK3E,OAAO,KAAK,EACV,OAAO,EACP,cAAc,EACd,kCAAkC,EAClC,sBAAsB,EAEtB,sBAAsB,EACtB,kBAAkB,EAClB,iBAAiB,EAIjB,8BAA8B,EAC/B,MAAM,YAAY,CAAC;AA+CpB;;;;;;;GAOG;AACH,qBACa,cAAe,YAAW,OAAO,EAAE,YAAY,EAAE,qBAAqB;IAMrE,OAAO,CAAC,QAAQ,CAAC,OAAO;IALpC,OAAO,CAAC,cAAc,CAA2C;IACjE,OAAO,CAAC,iBAAiB,CAA+B;IACxD,OAAO,CAAC,eAAe,CAA4B;IACnD,OAAO,CAAC,gBAAgB,CAAwC;gBAEnC,OAAO,EAAE,8BAA8B;IAE9D,qBAAqB,IAAI,OAAO,CAAC,IAAI,CAAC;YAgB9B,mBAAmB;IAe3B,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC;IAiCnC;;;;OAIG;IACH,4BAA4B;IAW5B;;;;;;;;;;;;;;OAcG;IACG,IAAI,CAAC,OAAO,EAAE,cAAc,EAAE,OAAO,GAAE,kBAAuB,GAAG,OAAO,CAAC,iBAAiB,CAAC;IA0BjG;;;;;;;;;;;;OAYG;IACG,QAAQ,CAAC,QAAQ,EAAE,SAAS,cAAc,EAAE,EAAE,OAAO,GAAE,sBAA2B,GAAG,OAAO,CAAC,sBAAsB,CAAC;IA6B1H;;;;;;;;;;;;;;;;OAgBG;IACG,gBAAgB,CACpB,YAAY,EAAE,kCAAkC,EAChD,OAAO,GAAE,kBAAuB,GAC/B,OAAO,CAAC,iBAAiB,CAAC;YAiCf,eAAe;IAuB7B,OAAO,CAAC,kBAAkB;IAU1B,OAAO,CAAC,gBAAgB;IAkBxB,OAAO,CAAC,2BAA2B;YAkBrB,kBAAkB;CAejC"}
1
+ {"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,qBAAqB,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAK3E,OAAO,KAAK,EACV,OAAO,EACP,cAAc,EACd,kCAAkC,EAClC,sBAAsB,EAEtB,sBAAsB,EACtB,kBAAkB,EAClB,iBAAiB,EAIjB,8BAA8B,EAC/B,MAAM,YAAY,CAAC;AA+DpB;;;;;;;GAOG;AACH,qBACa,cAAe,YAAW,OAAO,EAAE,YAAY,EAAE,qBAAqB;IAUrE,OAAO,CAAC,QAAQ,CAAC,OAAO;IATpC,OAAO,CAAC,QAAQ,CAAC,wBAAwB,CAA+B;IACxE,OAAO,CAAC,qBAAqB,CAAkD;IAC/E,OAAO,CAAC,cAAc,CAA2C;IACjE,OAAO,CAAC,4BAA4B,CAA4B;IAChE,OAAO,CAAC,iBAAiB,CAA+B;IACxD,OAAO,CAAC,eAAe,CAA4B;IACnD,OAAO,CAAC,gBAAgB,CAAwC;IAChE,OAAO,CAAC,4BAA4B,CAA4B;gBAEnC,OAAO,EAAE,8BAA8B;IAE9D,qBAAqB,IAAI,OAAO,CAAC,IAAI,CAAC;YAgB9B,mBAAmB;IAcjC,OAAO,CAAC,4BAA4B;YAQtB,gCAAgC;YAoBhC,0BAA0B;IAelC,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC;IA+BnC;;;;OAIG;IACH,4BAA4B;IAY5B;;;;;;;;;;;;;;OAcG;IACG,IAAI,CAAC,OAAO,EAAE,cAAc,EAAE,OAAO,GAAE,kBAAuB,GAAG,OAAO,CAAC,iBAAiB,CAAC;YAUnF,YAAY;IAuB1B;;;;;;;;;;;;OAYG;IACG,QAAQ,CAAC,QAAQ,EAAE,SAAS,cAAc,EAAE,EAAE,OAAO,GAAE,sBAA2B,GAAG,OAAO,CAAC,sBAAsB,CAAC;IA6B1H;;;;;;;;;;;;;;;;OAgBG;IACG,gBAAgB,CACpB,YAAY,EAAE,kCAAkC,EAChD,OAAO,GAAE,kBAAuB,GAC/B,OAAO,CAAC,iBAAiB,CAAC;YAYf,wBAAwB;YAkCxB,eAAe;IAuB7B,OAAO,CAAC,sBAAsB;YAMhB,uBAAuB;IAIrC,OAAO,CAAC,qBAAqB;YA2Bf,oCAAoC;IAuBlD,OAAO,CAAC,kBAAkB;IAU1B,OAAO,CAAC,gBAAgB;IAkBxB,OAAO,CAAC,2BAA2B;YAkBrB,kBAAkB;CAiBjC"}
package/dist/service.js CHANGED
@@ -16,12 +16,18 @@ function createAbortError() {
16
16
  function createStoppedTransportError() {
17
17
  return new DiscordTransportError('Discord transport is shutting down or already stopped.');
18
18
  }
19
- function createLifecycleReadinessError(lifecycleState) {
19
+ function createLifecycleReadinessError(lifecycleState, lifecycleFailurePhase) {
20
20
  if (lifecycleState === 'failed') {
21
+ if (lifecycleFailurePhase === 'shutdown-cleanup') {
22
+ return new DiscordTransportError('Discord transport failed during shutdown cleanup.');
23
+ }
21
24
  return new DiscordTransportError('Discord transport failed to initialize.');
22
25
  }
23
26
  return new DiscordTransportError('Discord transport is not ready for delivery.');
24
27
  }
28
+ function createCleanupFailureCause(originalError, cleanupError) {
29
+ return new AggregateError([originalError, cleanupError], 'Discord transport initialization failed and the owned transport failed to close.');
30
+ }
25
31
  function isShutdownLifecycleState(state) {
26
32
  return state === 'stopping' || state === 'stopped';
27
33
  }
@@ -47,10 +53,14 @@ class DiscordService {
47
53
  static {
48
54
  [_DiscordService, _initClass] = _applyDecs(this, [Inject(DISCORD_OPTIONS)], []).c;
49
55
  }
56
+ acceptedDeliveryPromises = new Set();
57
+ lifecycleFailurePhase;
50
58
  lifecycleState = 'created';
59
+ ownedTransportCleanupPromise;
51
60
  resolvedTransport;
52
61
  shutdownPromise;
53
62
  transportPromise;
63
+ transportVerificationPromise;
54
64
  constructor(options) {
55
65
  this.options = options;
56
66
  }
@@ -67,43 +77,69 @@ class DiscordService {
67
77
  }
68
78
  async closeOwnedTransport() {
69
79
  try {
70
- const transport = this.resolvedTransport ?? (this.transportPromise ? await this.transportPromise : undefined);
71
- if (transport && this.options.transport.ownsResources && transport.close) {
72
- await transport.close();
73
- }
80
+ await this.drainAcceptedDeliveries();
81
+ await this.closeOwnedTransportResources();
74
82
  this.lifecycleState = 'stopped';
83
+ this.lifecycleFailurePhase = undefined;
75
84
  } catch (error) {
76
85
  this.lifecycleState = 'failed';
86
+ this.lifecycleFailurePhase ??= 'shutdown-cleanup';
77
87
  throw new Error('Discord transport failed to close cleanly.', {
78
88
  cause: error
79
89
  });
80
90
  }
81
91
  }
92
+ closeOwnedTransportResources() {
93
+ if (!this.ownedTransportCleanupPromise) {
94
+ this.ownedTransportCleanupPromise = this.closeOwnedTransportResourcesOnce();
95
+ }
96
+ return this.ownedTransportCleanupPromise;
97
+ }
98
+ async closeOwnedTransportResourcesOnce() {
99
+ try {
100
+ const transport = await this.resolveTransportForCleanup();
101
+ const verificationPromise = this.transportVerificationPromise;
102
+ if (verificationPromise) {
103
+ await verificationPromise.then(() => undefined, () => undefined);
104
+ }
105
+ if (transport && this.options.transport.ownsResources && transport.close) {
106
+ await transport.close();
107
+ }
108
+ } finally {
109
+ this.clearResolvedTransport();
110
+ }
111
+ }
112
+ async resolveTransportForCleanup() {
113
+ if (this.resolvedTransport) {
114
+ return this.resolvedTransport;
115
+ }
116
+ if (!this.transportPromise) {
117
+ return undefined;
118
+ }
119
+ return this.transportPromise.then(transport => transport, () => undefined);
120
+ }
82
121
  async onModuleInit() {
83
122
  if (isShutdownLifecycleState(this.lifecycleState)) {
84
123
  return;
85
124
  }
86
125
  this.lifecycleState = 'starting';
126
+ this.lifecycleFailurePhase = undefined;
87
127
  try {
88
128
  const transport = await this.ensureTransport();
89
129
  if (this.lifecycleState !== 'starting') {
90
130
  return;
91
131
  }
92
132
  if (this.options.verifyOnModuleInit && transport.verify) {
93
- await transport.verify();
133
+ this.transportVerificationPromise = Promise.resolve(transport.verify());
134
+ await this.transportVerificationPromise;
94
135
  }
95
136
  if (this.lifecycleState !== 'starting') {
96
137
  return;
97
138
  }
98
139
  this.lifecycleState = 'ready';
140
+ this.lifecycleFailurePhase = undefined;
99
141
  } catch (error) {
100
- if (isShutdownLifecycleState(this.lifecycleState)) {
101
- throw error;
102
- }
103
- this.lifecycleState = 'failed';
104
- throw new Error('Discord transport failed to initialize.', {
105
- cause: error
106
- });
142
+ await this.handleTransportInitializationFailure(error);
107
143
  }
108
144
  }
109
145
 
@@ -116,6 +152,7 @@ class DiscordService {
116
152
  return createDiscordPlatformStatusSnapshot({
117
153
  channelName: this.options.notifications.channel,
118
154
  defaultThreadConfigured: this.options.defaultThreadId !== undefined,
155
+ lifecycleFailurePhase: this.lifecycleFailurePhase,
119
156
  lifecycleState: this.lifecycleState,
120
157
  ownsTransportResources: this.options.transport.ownsResources,
121
158
  transportKind: this.options.transport.kind,
@@ -143,8 +180,10 @@ class DiscordService {
143
180
  throw createAbortError();
144
181
  }
145
182
  this.assertReadyForSend();
146
- const transport = await this.ensureTransport();
147
- this.assertReadyForSend();
183
+ return this.trackAcceptedDelivery(() => this.sendAccepted(message, options, this.ensureTransport()));
184
+ }
185
+ async sendAccepted(message, options, transportPromise) {
186
+ const transport = await transportPromise;
148
187
  const normalized = this.normalizeMessage(message);
149
188
  assertMessageContent(normalized);
150
189
  const result = await transport.send(normalized, options);
@@ -220,9 +259,13 @@ class DiscordService {
220
259
  if (options.signal?.aborted) {
221
260
  throw createAbortError();
222
261
  }
262
+ this.assertReadyForSend();
263
+ return this.trackAcceptedDelivery(() => this.sendNotificationAccepted(notification, options, this.ensureTransport()));
264
+ }
265
+ async sendNotificationAccepted(notification, options, transportPromise) {
223
266
  const payload = notification.payload;
224
- const rendered = await this.renderNotification(notification);
225
- return this.send({
267
+ const rendered = await this.renderNotification(notification, options.signal);
268
+ return this.sendAccepted({
226
269
  allowedMentions: payload.allowedMentions,
227
270
  attachments: payload.attachments ?? [],
228
271
  avatarUrl: payload.avatarUrl,
@@ -245,14 +288,14 @@ class DiscordService {
245
288
  threadName: payload.threadName,
246
289
  tts: payload.tts,
247
290
  username: payload.username
248
- }, options);
291
+ }, options, transportPromise);
249
292
  }
250
293
  async ensureTransport() {
251
294
  if (this.lifecycleState === 'stopping' || this.lifecycleState === 'stopped') {
252
295
  throw createStoppedTransportError();
253
296
  }
254
297
  if (this.lifecycleState === 'failed') {
255
- throw createLifecycleReadinessError(this.lifecycleState);
298
+ throw createLifecycleReadinessError(this.lifecycleState, this.lifecycleFailurePhase);
256
299
  }
257
300
  if (this.resolvedTransport) {
258
301
  return this.resolvedTransport;
@@ -265,12 +308,58 @@ class DiscordService {
265
308
  }
266
309
  return this.transportPromise;
267
310
  }
311
+ clearResolvedTransport() {
312
+ this.resolvedTransport = undefined;
313
+ this.transportPromise = undefined;
314
+ this.transportVerificationPromise = undefined;
315
+ }
316
+ async drainAcceptedDeliveries() {
317
+ await Promise.allSettled(this.acceptedDeliveryPromises);
318
+ }
319
+ trackAcceptedDelivery(startDelivery) {
320
+ let rejectDelivery;
321
+ let resolveDelivery;
322
+ const delivery = new Promise((resolve, reject) => {
323
+ rejectDelivery = reject;
324
+ resolveDelivery = resolve;
325
+ });
326
+ this.acceptedDeliveryPromises.add(delivery);
327
+ void delivery.then(() => {
328
+ this.acceptedDeliveryPromises.delete(delivery);
329
+ }, () => {
330
+ this.acceptedDeliveryPromises.delete(delivery);
331
+ });
332
+ if (!resolveDelivery || !rejectDelivery) {
333
+ throw new Error('Discord delivery tracker failed to initialize.');
334
+ }
335
+ void startDelivery().then(resolveDelivery, rejectDelivery);
336
+ return delivery;
337
+ }
338
+ async handleTransportInitializationFailure(error) {
339
+ const interruptedByShutdown = isShutdownLifecycleState(this.lifecycleState);
340
+ this.lifecycleFailurePhase ??= 'initialization';
341
+ if (!interruptedByShutdown) {
342
+ this.lifecycleState = 'failed';
343
+ }
344
+ let cause = error;
345
+ try {
346
+ await this.closeOwnedTransportResources();
347
+ } catch (cleanupError) {
348
+ cause = createCleanupFailureCause(error, cleanupError);
349
+ }
350
+ if (!interruptedByShutdown && !isShutdownLifecycleState(this.lifecycleState)) {
351
+ this.lifecycleState = 'failed';
352
+ }
353
+ throw new Error('Discord transport failed to initialize.', {
354
+ cause
355
+ });
356
+ }
268
357
  assertReadyForSend() {
269
358
  if (this.lifecycleState === 'stopping' || this.lifecycleState === 'stopped') {
270
359
  throw createStoppedTransportError();
271
360
  }
272
361
  if (this.lifecycleState !== 'ready') {
273
- throw createLifecycleReadinessError(this.lifecycleState);
362
+ throw createLifecycleReadinessError(this.lifecycleState, this.lifecycleFailurePhase);
274
363
  }
275
364
  }
276
365
  normalizeMessage(message) {
@@ -301,7 +390,7 @@ class DiscordService {
301
390
  }
302
391
  return recipients[0] ?? this.options.defaultThreadId;
303
392
  }
304
- async renderNotification(notification) {
393
+ async renderNotification(notification, signal) {
305
394
  if (!notification.template || !this.options.renderer) {
306
395
  return undefined;
307
396
  }
@@ -309,6 +398,7 @@ class DiscordService {
309
398
  locale: notification.locale,
310
399
  metadata: notification.metadata,
311
400
  payload: notification.payload,
401
+ signal,
312
402
  subject: notification.subject,
313
403
  template: notification.template
314
404
  });
package/dist/status.d.ts CHANGED
@@ -5,6 +5,7 @@ export type DiscordLifecycleState = 'created' | 'starting' | 'ready' | 'stopping
5
5
  export interface DiscordStatusAdapterInput {
6
6
  channelName: string;
7
7
  defaultThreadConfigured: boolean;
8
+ lifecycleFailurePhase?: 'initialization' | 'shutdown-cleanup';
8
9
  lifecycleState: DiscordLifecycleState;
9
10
  ownsTransportResources: boolean;
10
11
  transportKind: string;
@@ -1 +1 @@
1
- {"version":3,"file":"status.d.ts","sourceRoot":"","sources":["../src/status.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,oBAAoB,EAAE,uBAAuB,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAEvG,+EAA+E;AAC/E,MAAM,MAAM,qBAAqB,GAAG,SAAS,GAAG,UAAU,GAAG,OAAO,GAAG,UAAU,GAAG,SAAS,GAAG,QAAQ,CAAC;AAEzG,wEAAwE;AACxE,MAAM,WAAW,yBAAyB;IACxC,WAAW,EAAE,MAAM,CAAC;IACpB,uBAAuB,EAAE,OAAO,CAAC;IACjC,cAAc,EAAE,qBAAqB,CAAC;IACtC,sBAAsB,EAAE,OAAO,CAAC;IAChC,aAAa,EAAE,MAAM,CAAC;IACtB,oBAAoB,EAAE,OAAO,CAAC;CAC/B;AAED,mFAAmF;AACnF,MAAM,WAAW,6BAA6B;IAC5C,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC,MAAM,EAAE,oBAAoB,CAAC;IAC7B,SAAS,EAAE,gBAAgB,CAAC,WAAW,CAAC,CAAC;IACzC,SAAS,EAAE,uBAAuB,CAAC;CACpC;AA6DD;;;;;GAKG;AACH,wBAAgB,mCAAmC,CAAC,KAAK,EAAE,yBAAyB,GAAG,6BAA6B,CAiBnH"}
1
+ {"version":3,"file":"status.d.ts","sourceRoot":"","sources":["../src/status.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,oBAAoB,EAAE,uBAAuB,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAEvG,+EAA+E;AAC/E,MAAM,MAAM,qBAAqB,GAAG,SAAS,GAAG,UAAU,GAAG,OAAO,GAAG,UAAU,GAAG,SAAS,GAAG,QAAQ,CAAC;AAEzG,wEAAwE;AACxE,MAAM,WAAW,yBAAyB;IACxC,WAAW,EAAE,MAAM,CAAC;IACpB,uBAAuB,EAAE,OAAO,CAAC;IACjC,qBAAqB,CAAC,EAAE,gBAAgB,GAAG,kBAAkB,CAAC;IAC9D,cAAc,EAAE,qBAAqB,CAAC;IACtC,sBAAsB,EAAE,OAAO,CAAC;IAChC,aAAa,EAAE,MAAM,CAAC;IACtB,oBAAoB,EAAE,OAAO,CAAC;CAC/B;AAED,mFAAmF;AACnF,MAAM,WAAW,6BAA6B;IAC5C,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC,MAAM,EAAE,oBAAoB,CAAC;IAC7B,SAAS,EAAE,gBAAgB,CAAC,WAAW,CAAC,CAAC;IACzC,SAAS,EAAE,uBAAuB,CAAC;CACpC;AAmED;;;;;GAKG;AACH,wBAAgB,mCAAmC,CAAC,KAAK,EAAE,yBAAyB,GAAG,6BAA6B,CAkBnH"}
package/dist/status.js CHANGED
@@ -28,7 +28,7 @@ function createReadiness(input) {
28
28
  if (input.lifecycleState === 'failed') {
29
29
  return {
30
30
  critical: true,
31
- reason: 'Discord transport failed to initialize.',
31
+ reason: input.lifecycleFailurePhase === 'shutdown-cleanup' ? 'Discord transport failed during shutdown cleanup.' : 'Discord transport failed to initialize.',
32
32
  status: 'not-ready'
33
33
  };
34
34
  }
@@ -41,7 +41,7 @@ function createReadiness(input) {
41
41
  function createHealth(input) {
42
42
  if (input.lifecycleState === 'failed' || input.lifecycleState === 'stopped') {
43
43
  return {
44
- reason: 'Discord transport is unavailable.',
44
+ reason: input.lifecycleState === 'failed' && input.lifecycleFailurePhase === 'shutdown-cleanup' ? 'Discord transport failed during shutdown cleanup.' : 'Discord transport is unavailable.',
45
45
  status: 'unhealthy'
46
46
  };
47
47
  }
@@ -68,6 +68,9 @@ export function createDiscordPlatformStatusSnapshot(input) {
68
68
  channelName: input.channelName,
69
69
  defaultThreadConfigured: input.defaultThreadConfigured,
70
70
  dependencies: ['notifications.channel', 'discord.transport'],
71
+ ...(input.lifecycleFailurePhase ? {
72
+ lifecycleFailurePhase: input.lifecycleFailurePhase
73
+ } : {}),
71
74
  lifecycleState: input.lifecycleState,
72
75
  transportKind: input.transportKind,
73
76
  verifiedOnModuleInit: input.verifiedOnModuleInit
package/dist/types.d.ts CHANGED
@@ -121,9 +121,17 @@ export type DiscordFetchLike = (input: string, init?: {
121
121
  method?: string;
122
122
  signal?: AbortSignal;
123
123
  }) => MaybePromise<DiscordFetchResponse>;
124
+ /** Bounded retry policy accepted by {@link createDiscordWebhookTransport}. */
125
+ export interface DiscordWebhookRetryOptions {
126
+ /** Total delivery attempts, including the initial request. Defaults to `3` and accepts values from `1` through `10`. */
127
+ attempts?: number;
128
+ /** Initial exponential-backoff delay in milliseconds. Defaults to `250` and accepts values from `0` through `60000`. */
129
+ baseDelayMs?: number;
130
+ }
124
131
  /** Options accepted by {@link createDiscordWebhookTransport}. */
125
132
  export interface DiscordWebhookTransportOptions {
126
133
  fetch?: DiscordFetchLike;
134
+ retry?: DiscordWebhookRetryOptions;
127
135
  wait?: boolean;
128
136
  webhookUrl: string;
129
137
  }
@@ -132,6 +140,8 @@ export interface DiscordTemplateRenderInput<TPayload extends DiscordNotification
132
140
  locale?: string;
133
141
  metadata?: Record<string, unknown>;
134
142
  payload: TPayload;
143
+ /** Caller cancellation signal shared with rendering and transport delivery. */
144
+ signal?: AbortSignal;
135
145
  subject?: string;
136
146
  template: string;
137
147
  }
@@ -147,7 +157,7 @@ export interface DiscordTemplateRenderer {
147
157
  * Renders one notification template into Discord content and/or embed fragments.
148
158
  *
149
159
  * @typeParam TPayload Payload shape carried by the notification request.
150
- * @param input Template render input including the template key and opaque payload.
160
+ * @param input Template render input including the template key, opaque payload, and caller cancellation signal.
151
161
  * @returns Rendered content or embed fragments that are merged with explicit payload overrides.
152
162
  */
153
163
  render<TPayload extends DiscordNotificationPayload = DiscordNotificationPayload>(input: DiscordTemplateRenderInput<TPayload>): MaybePromise<DiscordTemplateRenderResult>;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"webhook.d.ts","sourceRoot":"","sources":["../src/webhook.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAGV,gBAAgB,EAEhB,8BAA8B,EAE/B,MAAM,YAAY,CAAC;AAiIpB;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,6BAA6B,CAAC,OAAO,EAAE,8BAA8B,GAAG,gBAAgB,CAyEvG"}
1
+ {"version":3,"file":"webhook.d.ts","sourceRoot":"","sources":["../src/webhook.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAGV,gBAAgB,EAEhB,8BAA8B,EAE/B,MAAM,YAAY,CAAC;AA8JpB;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,6BAA6B,CAAC,OAAO,EAAE,8BAA8B,GAAG,gBAAgB,CAwEvG"}
package/dist/webhook.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { DiscordConfigurationError, DiscordTransportError } from './errors.js';
2
2
  const DEFAULT_RETRY_ATTEMPTS = 3;
3
3
  const DEFAULT_RETRY_DELAY_MS = 250;
4
+ const MAX_RETRY_ATTEMPTS = 10;
5
+ const MAX_RETRY_DELAY_MS = 60_000;
4
6
  function normalizeOptionalString(value) {
5
7
  const trimmed = value?.trim();
6
8
  return trimmed && trimmed.length > 0 ? trimmed : undefined;
@@ -89,7 +91,24 @@ function isAbortError(error) {
89
91
  function isTransientStatus(status) {
90
92
  return status === 408 || status === 429 || status >= 500 && status <= 599;
91
93
  }
94
+ function resolveRetryPolicy(retry) {
95
+ const attempts = retry?.attempts ?? DEFAULT_RETRY_ATTEMPTS;
96
+ if (!Number.isInteger(attempts) || attempts < 1 || attempts > MAX_RETRY_ATTEMPTS) {
97
+ throw new DiscordConfigurationError(`Discord webhook transport \`retry.attempts\` must be an integer between 1 and ${String(MAX_RETRY_ATTEMPTS)}.`);
98
+ }
99
+ const baseDelayMs = retry?.baseDelayMs ?? DEFAULT_RETRY_DELAY_MS;
100
+ if (!Number.isInteger(baseDelayMs) || baseDelayMs < 0 || baseDelayMs > MAX_RETRY_DELAY_MS) {
101
+ throw new DiscordConfigurationError(`Discord webhook transport \`retry.baseDelayMs\` must be an integer between 0 and ${String(MAX_RETRY_DELAY_MS)}.`);
102
+ }
103
+ return {
104
+ attempts,
105
+ baseDelayMs
106
+ };
107
+ }
92
108
  async function waitForRetry(delayMs, signal) {
109
+ if (signal?.aborted) {
110
+ throw signal.reason ?? new DOMException('The operation was aborted.', 'AbortError');
111
+ }
93
112
  if (delayMs <= 0) {
94
113
  return;
95
114
  }
@@ -144,12 +163,14 @@ export function createDiscordWebhookTransport(options) {
144
163
  }
145
164
  const parsedWebhookUrl = parseWebhookUrl(webhookUrl);
146
165
  const fetchLike = resolveFetch(options.fetch);
166
+ const retry = resolveRetryPolicy(options.retry);
147
167
  const wait = options.wait ?? true;
148
168
  return {
149
169
  async send(message, context) {
150
- for (let attempt = 1; attempt <= DEFAULT_RETRY_ATTEMPTS; attempt += 1) {
170
+ for (let attempt = 1; attempt <= retry.attempts; attempt += 1) {
171
+ let response;
151
172
  try {
152
- const response = await fetchLike(resolveWebhookUrl(parsedWebhookUrl, message, wait), {
173
+ response = await fetchLike(resolveWebhookUrl(parsedWebhookUrl, message, wait), {
153
174
  body: JSON.stringify(createWebhookPayload(message)),
154
175
  headers: {
155
176
  'content-type': 'application/json; charset=utf-8'
@@ -157,44 +178,41 @@ export function createDiscordWebhookTransport(options) {
157
178
  method: 'POST',
158
179
  signal: context.signal
159
180
  });
160
- const body = await readResponseBody(response);
161
- if (!response.ok) {
162
- if (attempt < DEFAULT_RETRY_ATTEMPTS && isTransientStatus(response.status)) {
163
- await waitForRetry(DEFAULT_RETRY_DELAY_MS * 2 ** (attempt - 1), context.signal);
164
- continue;
165
- }
166
- throw new DiscordTransportError(createStatusFailureMessage(response, attempt));
167
- }
168
- const parsed = parseJsonRecord(body);
169
- const warnings = [];
170
- if (body && !parsed) {
171
- warnings.push('Discord webhook returned a non-JSON success body.');
172
- }
173
- return {
174
- channelId: getStringField(parsed, 'channel_id'),
175
- guildId: getStringField(parsed, 'guild_id'),
176
- messageId: getStringField(parsed, 'id'),
177
- ok: true,
178
- response: body || undefined,
179
- statusCode: response.status,
180
- threadId: getStringField(parsed, 'thread_id') ?? message.threadId,
181
- warnings
182
- };
183
181
  } catch (error) {
184
182
  if (isAbortError(error) || context.signal?.aborted) {
185
183
  throw error;
186
184
  }
187
- if (error instanceof DiscordTransportError) {
188
- throw error;
189
- }
190
- if (attempt < DEFAULT_RETRY_ATTEMPTS) {
191
- await waitForRetry(DEFAULT_RETRY_DELAY_MS * 2 ** (attempt - 1), context.signal);
185
+ if (attempt < retry.attempts) {
186
+ await waitForRetry(retry.baseDelayMs * 2 ** (attempt - 1), context.signal);
192
187
  continue;
193
188
  }
194
189
  throw new DiscordTransportError(createTransportFailureMessage(attempt));
195
190
  }
191
+ const body = await readResponseBody(response);
192
+ if (!response.ok) {
193
+ if (attempt < retry.attempts && isTransientStatus(response.status)) {
194
+ await waitForRetry(retry.baseDelayMs * 2 ** (attempt - 1), context.signal);
195
+ continue;
196
+ }
197
+ throw new DiscordTransportError(createStatusFailureMessage(response, attempt));
198
+ }
199
+ const parsed = parseJsonRecord(body);
200
+ const warnings = [];
201
+ if (body && !parsed) {
202
+ warnings.push('Discord webhook returned a non-JSON success body.');
203
+ }
204
+ return {
205
+ channelId: getStringField(parsed, 'channel_id'),
206
+ guildId: getStringField(parsed, 'guild_id'),
207
+ messageId: getStringField(parsed, 'id'),
208
+ ok: true,
209
+ response: body || undefined,
210
+ statusCode: response.status,
211
+ threadId: getStringField(parsed, 'thread_id') ?? message.threadId,
212
+ warnings
213
+ };
196
214
  }
197
- throw new DiscordTransportError(createTransportFailureMessage(DEFAULT_RETRY_ATTEMPTS));
215
+ throw new DiscordTransportError(createTransportFailureMessage(retry.attempts));
198
216
  }
199
217
  };
200
218
  }
package/package.json CHANGED
@@ -9,7 +9,7 @@
9
9
  "portable",
10
10
  "fetch"
11
11
  ],
12
- "version": "1.0.4",
12
+ "version": "2.0.0",
13
13
  "private": false,
14
14
  "license": "MIT",
15
15
  "repository": {
@@ -18,7 +18,7 @@
18
18
  "directory": "packages/discord"
19
19
  },
20
20
  "engines": {
21
- "node": ">=20.0.0"
21
+ "node": ">=24.0.0 <27"
22
22
  },
23
23
  "publishConfig": {
24
24
  "access": "public"
@@ -36,13 +36,13 @@
36
36
  "dist"
37
37
  ],
38
38
  "dependencies": {
39
- "@fluojs/core": "^1.0.3",
40
- "@fluojs/di": "^1.1.0",
41
- "@fluojs/notifications": "^1.0.2",
42
- "@fluojs/runtime": "^1.1.8"
39
+ "@fluojs/core": "^2.0.0",
40
+ "@fluojs/di": "^3.0.0",
41
+ "@fluojs/notifications": "^2.0.0",
42
+ "@fluojs/runtime": "^3.0.0"
43
43
  },
44
44
  "devDependencies": {
45
- "vitest": "^3.2.4"
45
+ "vitest": "^4.1.11"
46
46
  },
47
47
  "scripts": {
48
48
  "prebuild": "node ../../tooling/scripts/clean-dist.mjs",