@h-ai/reach 0.1.0-alpha9 → 1.0.0-beta.2
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.en.md +238 -0
- package/README.ja.md +238 -0
- package/README.md +150 -73
- package/dist/index.d.ts +1031 -160
- package/dist/index.js +1069 -164
- package/dist/index.js.map +1 -1
- package/package.json +19 -14
package/README.en.md
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# @h-ai/reach
|
|
2
|
+
|
|
3
|
+
[简体中文](./README.md) | [English](./README.en.md) | [日本語](./README.ja.md)
|
|
4
|
+
|
|
5
|
+
[Framework home](../../README.en.md) · Related modules: [@h-ai/cache](../cache/README.en.md) · [@h-ai/core](../core/README.en.md) · [@h-ai/reldb](../reldb/README.en.md)
|
|
6
|
+
|
|
7
|
+
## Introduction
|
|
8
|
+
|
|
9
|
+
A notification module supporting multiple providers (email, SMS, API callbacks) at once. Templates bind to providers for scenario-based routing. Includes do-not-disturb (DND) policies and configuration-defined templates.
|
|
10
|
+
|
|
11
|
+
Template sends need only `template`, `to` and `vars`; the provider comes from the template. An explicit `provider` still overrides it. Direct sends require a provider; omitting it returns a failed HaiResult.
|
|
12
|
+
|
|
13
|
+
## Core features
|
|
14
|
+
|
|
15
|
+
| Provider | Channel | Description |
|
|
16
|
+
| ------------ | ------- | -------------------------------------------------- |
|
|
17
|
+
| `console` | General | Console output (development/tests) |
|
|
18
|
+
| `smtp` | Email | SMTP email (requires nodemailer) |
|
|
19
|
+
| `aliyun-sms` | SMS | Alibaba Cloud SMS through HTTP API, without an SDK |
|
|
20
|
+
| `api` | General | HTTP API callback (generic webhook) |
|
|
21
|
+
|
|
22
|
+
## Getting started
|
|
23
|
+
|
|
24
|
+
### Dependencies
|
|
25
|
+
|
|
26
|
+
- `@h-ai/reldb`: persistence for delivery logs and templates. With `dnd.enabled: true` and `strategy: delay`, initialize it first and ensure the queue table is available; otherwise `reach.init()` returns `CONFIG_ERROR`. Optional for other sends.
|
|
27
|
+
- `@h-ai/cache`: optional distributed locks for DND delay; enabled automatically when initialized.
|
|
28
|
+
|
|
29
|
+
### Quick start
|
|
30
|
+
|
|
31
|
+
#### Installation
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pnpm add @h-ai/reach
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Install `nodemailer` for email delivery.
|
|
38
|
+
|
|
39
|
+
#### Initialize multiple providers
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import { reach } from '@h-ai/reach'
|
|
43
|
+
|
|
44
|
+
const initialized = await reach.init({
|
|
45
|
+
providers: [{ name: 'local', type: 'console' }],
|
|
46
|
+
templates: [{ name: 'welcome', provider: 'local', subject: 'Welcome', body: 'Hello {userName}' }],
|
|
47
|
+
})
|
|
48
|
+
if (!initialized.success)
|
|
49
|
+
throw new Error(initialized.error.message)
|
|
50
|
+
try {
|
|
51
|
+
const result = await reach.send({
|
|
52
|
+
to: 'user@example.com',
|
|
53
|
+
template: 'welcome',
|
|
54
|
+
vars: { userName: 'Alice' },
|
|
55
|
+
})
|
|
56
|
+
if (!result.success)
|
|
57
|
+
throw new Error(result.error.message)
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
await reach.close()
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
This example uses the development Console provider. It records delivery metadata and does not send email or SMS. Configure SMTP, SMS or API providers below for real delivery; initialize optional persistence/shared cache when needed.
|
|
65
|
+
|
|
66
|
+
`reach.config` returns a **redacted configuration snapshot**. Embedded credentials in `api.url` / `endpoint`, `headers.Authorization`, SMTP `pass`, SMS keys, and other secrets are hidden automatically.
|
|
67
|
+
|
|
68
|
+
### Configuration
|
|
69
|
+
|
|
70
|
+
#### Console (development/tests)
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
const config = { name: 'dev', type: 'console' }
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
#### SMTP
|
|
77
|
+
|
|
78
|
+
| Field | Type | Required | Default | Description |
|
|
79
|
+
| -------- | --------- | -------- | ------- | ----------------------- |
|
|
80
|
+
| `name` | `string` | ✅ | — | Provider name |
|
|
81
|
+
| `type` | `string` | ✅ | — | Fixed `'smtp'` |
|
|
82
|
+
| `host` | `string` | ✅ | — | SMTP server address |
|
|
83
|
+
| `port` | `number` | — | `465` | SMTP port |
|
|
84
|
+
| `secure` | `boolean` | — | `true` | Whether to use TLS |
|
|
85
|
+
| `user` | `string` | — | — | Authentication username |
|
|
86
|
+
| `pass` | `string` | — | — | Authentication password |
|
|
87
|
+
| `from` | `string` | ✅ | — | Sender address |
|
|
88
|
+
|
|
89
|
+
#### Alibaba Cloud SMS
|
|
90
|
+
|
|
91
|
+
| Field | Type | Required | Default | Description |
|
|
92
|
+
| ----------------- | -------- | -------- | ----------------------- | -------------------- |
|
|
93
|
+
| `name` | `string` | ✅ | — | Provider name |
|
|
94
|
+
| `type` | `string` | ✅ | — | Fixed `'aliyun-sms'` |
|
|
95
|
+
| `accessKeyId` | `string` | ✅ | — | AccessKey ID |
|
|
96
|
+
| `accessKeySecret` | `string` | ✅ | — | AccessKey Secret |
|
|
97
|
+
| `signName` | `string` | ✅ | — | SMS signature |
|
|
98
|
+
| `endpoint` | `string` | — | `dysmsapi.aliyuncs.com` | API endpoint |
|
|
99
|
+
|
|
100
|
+
#### API callbacks
|
|
101
|
+
|
|
102
|
+
| Field | Type | Required | Default | Description |
|
|
103
|
+
| ------------ | ------------------------ | -------- | ------- | ------------------------------------------------------------------------------------ |
|
|
104
|
+
| `name` | `string` | ✅ | — | Provider name |
|
|
105
|
+
| `type` | `string` | ✅ | — | Fixed `'api'` |
|
|
106
|
+
| `url` | `string` | ✅ | — | HTTP(S) callback URL |
|
|
107
|
+
| `method` | `'POST' \| 'PUT'` | — | `POST` | HTTP method |
|
|
108
|
+
| `headers` | `Record<string, string>` | — | — | Custom request headers |
|
|
109
|
+
| `timeout` | `number` | — | `10000` | Timeout in milliseconds |
|
|
110
|
+
| `idempotent` | `boolean` | — | `false` | Enable only when the receiver atomically persists deduplication with its side effect |
|
|
111
|
+
|
|
112
|
+
API callback addresses are trusted server configuration. Logs contain only the callback origin, not URL credentials, query parameters, recipients, or third-party failure bodies. For tenant/user-supplied addresses, applications must enforce a domain allowlist; enterprise deployments should also block internal metadata access through egress proxies or network policies.
|
|
113
|
+
|
|
114
|
+
#### DND (do not disturb)
|
|
115
|
+
|
|
116
|
+
| Field | Type | Required | Default | Description |
|
|
117
|
+
| ---------- | ---------------------- | -------- | --------- | ------------------------------------- |
|
|
118
|
+
| `enabled` | `boolean` | — | `false` | Enable DND |
|
|
119
|
+
| `strategy` | `'discard' \| 'delay'` | — | `discard` | discard: drop / delay: send after DND |
|
|
120
|
+
| `start` | `string` | — | `00:00` | Start time (HH:mm) |
|
|
121
|
+
| `end` | `string` | — | `00:00` | End time (HH:mm) |
|
|
122
|
+
|
|
123
|
+
`delay` returns a successful `HaiResult` containing `{ success: true, deferred: true, queueId }` only after the database confirms the insert. This means queued, awaiting delivery. The receipt UUID is stored in `hai_reach_send_log.queue_id`; its row tracks `status` (`pending` / `processing` / `sending` / `unknown` / `sent` / `discarded` / `dead`). Missing storage, disconnection, or insertion failure never produces deferred success; enqueue failures return `SEND_FAILED`. After restart, another node connected to the same database with the same Provider name can resume delivery outside DND. Optional log failures after an immediate send do not report an already delivered message as failed.
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
const result = await reach.send(message)
|
|
127
|
+
if (!result.success)
|
|
128
|
+
throw new Error(result.error.message)
|
|
129
|
+
if (result.data.deferred) {
|
|
130
|
+
// Save the receipt for tracking; delivery is still pending.
|
|
131
|
+
await savePendingReceipt(result.data.queueId)
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Queue columns `created_at`, `updated_at`, `locked_until`, and `next_attempt_at` store Unix milliseconds as `BIGINT` (64-bit `INTEGER` on SQLite). The Reach repository normalizes integer strings returned by drivers such as PostgreSQL into safe integer `number` values. Negative, fractional, or unsafe stored integers return `SEND_FAILED` instead of being silently rounded. Template creation/update `TIMESTAMP` fields map to `Date`. Update the complete table schema during deployment, including attempt counters, backoff policy, and failure classification; module-owned versioned migrations handle supported legacy tables (below).
|
|
136
|
+
|
|
137
|
+
#### Durable retry policy
|
|
138
|
+
|
|
139
|
+
The `queue` policy is persisted with each queued message. A restart or another node's changed configuration cannot reset an existing message's budget.
|
|
140
|
+
|
|
141
|
+
| Setting | Default | Bounds |
|
|
142
|
+
| ----------------------- | --------- | -------------------------------------------------- |
|
|
143
|
+
| `queue.maxAttempts` | `5` | 1–100, including initial claims and crash recovery |
|
|
144
|
+
| `queue.retryDelayMs` | `60000` | Initial backoff, 1000–86400000 milliseconds |
|
|
145
|
+
| `queue.maxRetryDelayMs` | `3600000` | Same bounds, at least retryDelayMs |
|
|
146
|
+
|
|
147
|
+
Every claim atomically increments the stored `attemptCount`. Transient failures become eligible at `now + min(maxRetryDelayMs, retryDelayMs × 2^(attemptCount−1))`; the minute-based scan outside DND can deliver later. A round handles at most 100 messages using a fixed eligibility cutoff, so failed messages cannot reenter that round. Missing/disconnected providers, network errors, and HTTP 408/425/429/5xx allow bounded retry. Invalid stored JSON and other HTTP 4xx from an idempotent API go directly to `dead`. Exhaustion, including a crash after the last claim, also produces dead. In-flight non-idempotent requests still become `unknown` regardless of remaining attempts.
|
|
148
|
+
|
|
149
|
+
### Error handling
|
|
150
|
+
|
|
151
|
+
All operations return `HaiResult<T>`. Error codes are defined in `HaiReachError`:
|
|
152
|
+
|
|
153
|
+
| Error code | code | Description |
|
|
154
|
+
| -------------------------------------- | --------------- | ------------------------- |
|
|
155
|
+
| `HaiReachError.SEND_FAILED` | `hai:reach:001` | Delivery failed |
|
|
156
|
+
| `HaiReachError.TEMPLATE_NOT_FOUND` | `hai:reach:002` | Template not found |
|
|
157
|
+
| `HaiReachError.TEMPLATE_RENDER_FAILED` | `hai:reach:003` | Template rendering failed |
|
|
158
|
+
| `HaiReachError.INVALID_RECIPIENT` | `hai:reach:004` | Invalid recipient |
|
|
159
|
+
| `HaiReachError.PROVIDER_NOT_FOUND` | `hai:reach:005` | Provider not registered |
|
|
160
|
+
| `HaiReachError.DND_BLOCKED` | `hai:reach:006` | Dropped by DND |
|
|
161
|
+
| `HaiReachError.NOT_INITIALIZED` | `hai:reach:010` | Not initialized |
|
|
162
|
+
| `HaiReachError.UNSUPPORTED_TYPE` | `hai:reach:011` | Unsupported type |
|
|
163
|
+
| `HaiReachError.CONFIG_ERROR` | `hai:reach:012` | Invalid configuration |
|
|
164
|
+
| `HaiReachError.DELIVERY_REJECTED` | `hai:reach:020` | Permanent API rejection |
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
const result = await reach.send({ provider: 'email', to: 'user@example.com', body: 'hello' })
|
|
168
|
+
if (!result.success) {
|
|
169
|
+
// result.error.code / result.error.message
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### Distributed locks
|
|
174
|
+
|
|
175
|
+
The optional cache lock reduces duplicate scans; nodes can scan concurrently after its 300-second TTL. Database delivery states provide correctness. A round claims at most 100 messages, one immediately before each delivery. An atomic owner/lease check persists `sending` before the external call; in-flight leases renew every third of their duration. Expired owners cannot revive leases, start another delivery, or commit results. Recovery continues every minute outside DND.
|
|
176
|
+
|
|
177
|
+
- States follow `pending → processing → sending → sent`. Expired processing records can be claimed safely.
|
|
178
|
+
- Only API providers configured with `idempotent: true` can automatically retry in-flight requests. This declares receiver capability. Every attempt uses the persistent queueId as both the `Idempotency-Key` header and `idempotencyKey` payload field. The receiver must atomically commit deduplication and side effects. Retention must cover late requests from resumed old workers; if lateness is unbounded, deduplication records must not expire. The same Provider name across nodes must use the same target and deduplication store.
|
|
179
|
+
- SMTP, Aliyun SMS, and default API providers have no external idempotency guarantee. Failure, exceptions, or lease loss after sending begins produce an unknown outcome. Expired non-idempotent sends become `unknown` and are never automatically resent. Disconnected providers use persisted backoff; invalid stored JSON goes directly to dead before any external call. Console produces development metadata only.
|
|
180
|
+
- Lease renewal, owner checks, and cancellation cannot undo an email, SMS, or HTTP side effect. The module provides no general exactly-once guarantee.
|
|
181
|
+
|
|
182
|
+
### Reviewing unknown outcomes
|
|
183
|
+
|
|
184
|
+
Trusted server administration can call `reach.queue.get(queueId)` to inspect `pending / processing / sending / unknown / sent / discarded / dead` without recipient or body data. For unknown outcomes, stop or isolate the old worker and verify Provider delivery records first. Then resolve to sent or discarded. UUID/status validation and a conditional update allow only one concurrent resolution; this never sends again. Discarding cannot cancel requests already started. Create a replacement only after confirming the old request can no longer take effect.
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
const state = await reach.queue.get(queueId)
|
|
188
|
+
const resolved = await reach.queue.resolveUnknown(queueId, { status: 'sent', messageId: verifiedMessageId })
|
|
189
|
+
if (!resolved.success)
|
|
190
|
+
throw new Error(resolved.error.message)
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### Inspecting and restoring dead messages
|
|
194
|
+
|
|
195
|
+
Trusted server administration can call `reach.queue.list({ status: 'dead', limit: 50, offset: 0 })`, or list `unknown`. Pages contain at most 100 records and offset is bounded by 1000000. Get/list return status, attemptCount, maxAttempts, nextAttemptAt, lastFailure, and updatedAt without recipients, bodies, or owner credentials. Fix the root cause, read the latest dead version, and conditionally restore it:
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
const state = await reach.queue.get(queueId)
|
|
199
|
+
if (!state.success)
|
|
200
|
+
throw new Error(state.error.message)
|
|
201
|
+
if (state.data?.status === 'dead' && state.data.updatedAt !== undefined) {
|
|
202
|
+
const restored = await reach.queue.requeueDead(queueId, { expectedUpdatedAt: state.data.updatedAt })
|
|
203
|
+
if (!restored.success)
|
|
204
|
+
throw new Error(restored.error.message)
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Only the matching dead version is restored; concurrent/stale requests fail, and unknown cannot be requeued. Restoration resets attempts while retaining queueId, the original policy, and receiver idempotency requirements. Dead means automatic processing stopped, not that no external effect occurred: late requests from an earlier idempotent API worker may still arrive. Confirm durable receiver deduplication before restoring. Leave irreparable payloads in dead for investigation; restoration does not repair their content.
|
|
209
|
+
|
|
210
|
+
## Production shared state
|
|
211
|
+
|
|
212
|
+
Production rejects console providers and requires SMTP, SMS or an actual API service. A log write is not a delivery receipt. Reconcile unknown outcomes from non-idempotent providers rather than claiming exactly-once delivery. Initialization checks apply when NODE_ENV or HAI_CORE_ENV is production; only explicit HAI_E2E=1/HAI_E2E_MOCK=1 tests are exempt. [State, concurrency and recovery (Chinese)](../../.agents/instructions/distributed-state.md)。
|
|
213
|
+
|
|
214
|
+
## Credential notifications and upgrades
|
|
215
|
+
|
|
216
|
+
Use `reach.send({ ..., sensitive: true, expiresAt, idempotencyKey })` for passwords, OTPs and reset tokens. expiresAt is Unix milliseconds; stable operation keys must contain no credentials. A shared ledger stores only metadata receipts/status and redacted destinations, never bodies, variables or credential hashes. There is no delayed credential queue. Success returns receiptId; DND/expired messages are not sent, and uncertain delivery is never automatically repeated. Explicit development console output is isolated from persistent observers. Production Console restrictions remain. Normal messages may use deferPolicy: reject to avoid a second queue.
|
|
217
|
+
|
|
218
|
+
`migrateReach({ dryRun: true })` previews supported migrations, applied during repository initialization. Statuses/identifiers remain; legacy in-flight work becomes unknown. Legacy payloads are retired once and pending old payloads discarded. Back up and stop old writers before upgrading; never replay old credentials.
|
|
219
|
+
|
|
220
|
+
## API acknowledgements and destination boundaries
|
|
221
|
+
|
|
222
|
+
An empty successful 2xx response, including 204/205, means receiver acceptance; messageId is optional and acceptance does not prove final delivery. Nonempty responses must be JSON objects with an optional messageId string of at most 255 characters. Responses are bounded to 64 KiB. Invalid responses, timeouts and network failures never become false success or expose receiver content.
|
|
223
|
+
|
|
224
|
+
API `redirect` defaults to `error`; ordinary messages may explicitly use `follow`. Sensitive messages always reject redirects without forwarding payloads or private headers. `requireHttps:true` enforces initial HTTPS and cannot be combined with follow. When omitted, production sensitive messages require HTTPS; explicit false is only for an operator-approved trusted internal connection. Unknown source-side effects still require reconciliation and sensitive messages are never retried automatically. Application allowlists and network egress controls remain necessary.
|
|
225
|
+
|
|
226
|
+
PostgreSQL/MySQL queue claims, renewals and final acknowledgements use the database execution clock inside SQL; batch cutoffs also come from the database. Node clock skew and row-lock waits cannot revive an expired owner. Local SQLite remains a development/test backend with a controllable test clock.
|
|
227
|
+
|
|
228
|
+
## Development and testing
|
|
229
|
+
|
|
230
|
+
```bash
|
|
231
|
+
pnpm --filter @h-ai/reach test
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
`tests/reach-repository-send-log-databases.test.ts` starts isolated PostgreSQL 16.15 / MySQL 8.4.11 containers through Testcontainers and removes them afterward. A reachable Docker API is required. For Podman, point `DOCKER_HOST` at its compatible socket; rootless environments can set `TESTCONTAINERS_RYUK_DISABLED=true` as required by Testcontainers.
|
|
235
|
+
|
|
236
|
+
## License
|
|
237
|
+
|
|
238
|
+
Apache-2.0
|
package/README.ja.md
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# @h-ai/reach
|
|
2
|
+
|
|
3
|
+
[简体中文](./README.md) | [English](./README.en.md) | [日本語](./README.ja.md)
|
|
4
|
+
|
|
5
|
+
[フレームワークのホーム](../../README.ja.md) · 関連モジュール:[@h-ai/cache](../cache/README.ja.md) · [@h-ai/core](../core/README.ja.md) · [@h-ai/reldb](../reldb/README.ja.md)
|
|
6
|
+
|
|
7
|
+
## 概要
|
|
8
|
+
|
|
9
|
+
メール・SMS・API 通知など複数の Provider を同時登録できる通知モジュールです。テンプレートと Provider の対応で用途に応じて配信します。通知抑制(DND)と設定ファイルによるテンプレート定義を備えます。
|
|
10
|
+
|
|
11
|
+
テンプレート送信は `template`、`to`、`vars` のみで利用でき、Provider はテンプレートから選ばれます。明示した `provider` は優先されます。直接送信は Provider が必須で、省略すると失敗 HaiResult を返します。
|
|
12
|
+
|
|
13
|
+
## 主な機能
|
|
14
|
+
|
|
15
|
+
| Provider | チャネル | 説明 |
|
|
16
|
+
| ------------ | -------- | -------------------------------------------------- |
|
|
17
|
+
| `console` | 汎用 | コンソール出力(開発・テスト用) |
|
|
18
|
+
| `smtp` | メール | SMTP メール(nodemailer が必要) |
|
|
19
|
+
| `aliyun-sms` | SMS | Alibaba Cloud SMS(HTTP API を直接使用、SDK 不要) |
|
|
20
|
+
| `api` | 汎用 | HTTP API 通知(汎用 webhook) |
|
|
21
|
+
|
|
22
|
+
## 使い始める
|
|
23
|
+
|
|
24
|
+
### 依存関係
|
|
25
|
+
|
|
26
|
+
- `@h-ai/reldb`:送信ログ・テンプレートの永続化。`dnd.enabled: true` と `strategy: delay` を使う場合は事前初期化とキューテーブルが必須で、利用できなければ `reach.init()` は `CONFIG_ERROR` を返します。それ以外の送信では任意です。
|
|
27
|
+
- `@h-ai/cache`:DND delay 用の分散ロック(任意)。初期化済みなら自動有効化。
|
|
28
|
+
|
|
29
|
+
### クイックスタート
|
|
30
|
+
|
|
31
|
+
#### インストール
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pnpm add @h-ai/reach
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
メール送信には `nodemailer` をインストールしてください。
|
|
38
|
+
|
|
39
|
+
#### 複数 Provider の初期化
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import { reach } from '@h-ai/reach'
|
|
43
|
+
|
|
44
|
+
const initialized = await reach.init({
|
|
45
|
+
providers: [{ name: 'local', type: 'console' }],
|
|
46
|
+
templates: [{ name: 'welcome', provider: 'local', subject: 'Welcome', body: 'Hello {userName}' }],
|
|
47
|
+
})
|
|
48
|
+
if (!initialized.success)
|
|
49
|
+
throw new Error(initialized.error.message)
|
|
50
|
+
try {
|
|
51
|
+
const result = await reach.send({
|
|
52
|
+
to: 'user@example.com',
|
|
53
|
+
template: 'welcome',
|
|
54
|
+
vars: { userName: 'Alice' },
|
|
55
|
+
})
|
|
56
|
+
if (!result.success)
|
|
57
|
+
throw new Error(result.error.message)
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
await reach.close()
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
この例は開発用 Console Provider で送信メタデータを記録し、実メールや SMS は送りません。実送信には下記の SMTP・SMS・API を設定し、必要に応じて永続化と共有キャッシュを初期化してください。
|
|
65
|
+
|
|
66
|
+
`reach.config` は**機密情報をマスクした設定スナップショット**を返します。`api.url` / `endpoint` 内の認証情報、`headers.Authorization`、SMTP の `pass`、SMS の鍵などは自動的に非表示になります。
|
|
67
|
+
|
|
68
|
+
### 設定
|
|
69
|
+
|
|
70
|
+
#### Console(開発・テスト用)
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
const config = { name: 'dev', type: 'console' }
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
#### SMTP
|
|
77
|
+
|
|
78
|
+
| フィールド | 型 | 必須 | 既定値 | 説明 |
|
|
79
|
+
| ---------- | --------- | ---- | ------ | ----------------------- |
|
|
80
|
+
| `name` | `string` | ✅ | — | Provider 名 |
|
|
81
|
+
| `type` | `string` | ✅ | — | 固定値 `'smtp'` |
|
|
82
|
+
| `host` | `string` | ✅ | — | SMTP サーバーのアドレス |
|
|
83
|
+
| `port` | `number` | — | `465` | SMTP ポート |
|
|
84
|
+
| `secure` | `boolean` | — | `true` | TLS を使用するか |
|
|
85
|
+
| `user` | `string` | — | — | 認証ユーザー名 |
|
|
86
|
+
| `pass` | `string` | — | — | 認証パスワード |
|
|
87
|
+
| `from` | `string` | ✅ | — | 送信元アドレス |
|
|
88
|
+
|
|
89
|
+
#### Alibaba Cloud SMS
|
|
90
|
+
|
|
91
|
+
| フィールド | 型 | 必須 | 既定値 | 説明 |
|
|
92
|
+
| ----------------- | -------- | ---- | ----------------------- | --------------------- |
|
|
93
|
+
| `name` | `string` | ✅ | — | Provider 名 |
|
|
94
|
+
| `type` | `string` | ✅ | — | 固定値 `'aliyun-sms'` |
|
|
95
|
+
| `accessKeyId` | `string` | ✅ | — | AccessKey ID |
|
|
96
|
+
| `accessKeySecret` | `string` | ✅ | — | AccessKey Secret |
|
|
97
|
+
| `signName` | `string` | ✅ | — | SMS 署名 |
|
|
98
|
+
| `endpoint` | `string` | — | `dysmsapi.aliyuncs.com` | API endpoint |
|
|
99
|
+
|
|
100
|
+
#### API 通知
|
|
101
|
+
|
|
102
|
+
| フィールド | 型 | 必須 | 既定値 | 説明 |
|
|
103
|
+
| ------------ | ------------------------ | ---- | ------- | ---------------------------------------------------------- |
|
|
104
|
+
| `name` | `string` | ✅ | — | Provider 名 |
|
|
105
|
+
| `type` | `string` | ✅ | — | 固定値 `'api'` |
|
|
106
|
+
| `url` | `string` | ✅ | — | HTTP(S) 通知 URL |
|
|
107
|
+
| `method` | `'POST' \| 'PUT'` | — | `POST` | HTTP メソッド |
|
|
108
|
+
| `headers` | `Record<string, string>` | — | — | 独自のリクエストヘッダー |
|
|
109
|
+
| `timeout` | `number` | — | `10000` | タイムアウト(ミリ秒) |
|
|
110
|
+
| `idempotent` | `boolean` | — | `false` | 受信側が副作用と重複排除を原子的に永続化する場合のみ有効化 |
|
|
111
|
+
|
|
112
|
+
API 通知先は信頼できるサーバー設定です。ログには origin のみを記録し、URL 認証情報、クエリ、宛先、外部の失敗応答本文は残しません。テナント・ユーザー入力の URL にはアプリでドメインの allowlist を設け、企業環境では出口プロキシやネットワーク方針でも内部メタデータへの接続を防いでください。
|
|
113
|
+
|
|
114
|
+
#### DND(通知抑制)
|
|
115
|
+
|
|
116
|
+
| フィールド | 型 | 必須 | 既定値 | 説明 |
|
|
117
|
+
| ---------- | ---------------------- | ---- | --------- | --------------------------------------- |
|
|
118
|
+
| `enabled` | `boolean` | — | `false` | 有効化するか |
|
|
119
|
+
| `strategy` | `'discard' \| 'delay'` | — | `discard` | discard:破棄 / delay:DND 終了後に送信 |
|
|
120
|
+
| `start` | `string` | — | `00:00` | 開始時刻(HH:mm) |
|
|
121
|
+
| `end` | `string` | — | `00:00` | 終了時刻(HH:mm) |
|
|
122
|
+
|
|
123
|
+
`delay` はデータベースへの挿入成功を確認した後だけ、`{ success: true, deferred: true, queueId }` を含む成功 `HaiResult` を返します。これはキューへの保存完了を表し、配信はまだ完了していません。受付 UUID は `hai_reach_send_log.queue_id` に保存され、同じ行の `status`(`pending` / `processing` / `sending` / `unknown` / `sent` / `discarded` / `dead`)で追跡できます。ストレージ未準備、切断、挿入失敗では deferred 成功を返さず、キューへの保存失敗は `SEND_FAILED` です。再起動後、同じデータベースに接続して同名 Provider を設定した別ノードが DND 時間外に配信を再開できます。即時送信後の任意ログの失敗によって、配信済みメッセージを失敗扱いにはしません。
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
const result = await reach.send(message)
|
|
127
|
+
if (!result.success)
|
|
128
|
+
throw new Error(result.error.message)
|
|
129
|
+
if (result.data.deferred) {
|
|
130
|
+
// 追跡用の受付 ID を保存します。配信はまだ完了していません。
|
|
131
|
+
await savePendingReceipt(result.data.queueId)
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
キューの `created_at`、`updated_at`、`locked_until`、`next_attempt_at` は Unix ミリ秒を `BIGINT`(SQLite では 64 ビット `INTEGER`)として保存します。Reach リポジトリーは PostgreSQL などのドライバーが返す整数文字列を安全な整数の `number` に変換します。負数、小数、JS の安全整数範囲外の保存値は丸めずに `SEND_FAILED` を返します。テンプレートの作成・更新 `TIMESTAMP` は `Date` に対応します。デプロイ時に試行回数・退避設定・失敗分類を含むテーブル構造も更新してください。対応する旧表はモジュール所有のバージョン移行で更新します(下記)。
|
|
136
|
+
|
|
137
|
+
#### 永続化された再試行設定
|
|
138
|
+
|
|
139
|
+
`queue` 設定は各メッセージの保存時に永続化され、再起動や別ノードの設定変更では既存の予算をリセットしません。
|
|
140
|
+
|
|
141
|
+
| 設定 | 既定値 | 範囲 |
|
|
142
|
+
| ----------------------- | --------- | ------------------------------------- |
|
|
143
|
+
| `queue.maxAttempts` | `5` | 1–100、初回取得とクラッシュ復旧を含む |
|
|
144
|
+
| `queue.retryDelayMs` | `60000` | 初回退避、1000–86400000 ミリ秒 |
|
|
145
|
+
| `queue.maxRetryDelayMs` | `3600000` | 同じ範囲、retryDelayMs 以上 |
|
|
146
|
+
|
|
147
|
+
取得ごとに保存済み `attemptCount` を原子的に増やします。一時的失敗は `now + min(maxRetryDelayMs, retryDelayMs × 2^(attemptCount−1))` 以降に再取得でき、DND 外の毎分走査によって実際の送信はさらに遅れる場合があります。1 回の走査は最大 100 件で取得資格の時刻を固定するため、失敗したメッセージが同じ回を繰り返し占有しません。Provider 未設定・未接続、ネットワーク障害、HTTP 408/425/429/5xx は有限回再試行します。不正な保存 JSON と冪等 API の他の HTTP 4xx は直ちに `dead`、上限到達や最後の取得後のクラッシュも dead になります。冪等性のない配信中リクエストは残り回数によらず `unknown` に隔離します。
|
|
148
|
+
|
|
149
|
+
### エラー処理
|
|
150
|
+
|
|
151
|
+
すべての操作は `HaiResult<T>` を返します。エラーコードは `HaiReachError` で定義します:
|
|
152
|
+
|
|
153
|
+
| エラーコード | code | 説明 |
|
|
154
|
+
| -------------------------------------- | --------------- | ------------------------ |
|
|
155
|
+
| `HaiReachError.SEND_FAILED` | `hai:reach:001` | 送信に失敗 |
|
|
156
|
+
| `HaiReachError.TEMPLATE_NOT_FOUND` | `hai:reach:002` | テンプレートが存在しない |
|
|
157
|
+
| `HaiReachError.TEMPLATE_RENDER_FAILED` | `hai:reach:003` | テンプレート描画に失敗 |
|
|
158
|
+
| `HaiReachError.INVALID_RECIPIENT` | `hai:reach:004` | 無効な宛先 |
|
|
159
|
+
| `HaiReachError.PROVIDER_NOT_FOUND` | `hai:reach:005` | Provider が未登録 |
|
|
160
|
+
| `HaiReachError.DND_BLOCKED` | `hai:reach:006` | DND により破棄 |
|
|
161
|
+
| `HaiReachError.NOT_INITIALIZED` | `hai:reach:010` | 未初期化 |
|
|
162
|
+
| `HaiReachError.UNSUPPORTED_TYPE` | `hai:reach:011` | 未対応の種類 |
|
|
163
|
+
| `HaiReachError.CONFIG_ERROR` | `hai:reach:012` | 設定エラー |
|
|
164
|
+
| `HaiReachError.DELIVERY_REJECTED` | `hai:reach:020` | API による恒久的な拒否 |
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
const result = await reach.send({ provider: 'email', to: 'user@example.com', body: 'hello' })
|
|
168
|
+
if (!result.success) {
|
|
169
|
+
// result.error.code / result.error.message
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### 分散ロック
|
|
174
|
+
|
|
175
|
+
任意のキャッシュロックは重複走査を減らすためのもので、300 秒後には複数ノードが走査できます。正しさはデータベースの配信状態で保証します。1 回に最大 100 件を 1 件ずつ取得し、外部呼び出し前に有効な owner/lease を原子的に確認して sending を保存します。配信中はリース期間の 3 分の 1 ごとに更新し、期限切れ owner の復活・再送・結果確定を拒否します。DND 時間外は毎分復旧を試みます。
|
|
176
|
+
|
|
177
|
+
- 状態は `pending → processing → sending → sent`。期限切れ processing は安全に再取得できます。
|
|
178
|
+
- `idempotent: true` の API のみ配信中リクエストを自動再試行します。これは受信側の能力宣言です。各試行の `Idempotency-Key` ヘッダーと `idempotencyKey` 本文は永続化された queueId を使い、受信側が重複排除と副作用を原子的に確定する必要があります。古い worker の遅延復帰も保持期間に含め、最大遅延を限定できない場合は重複排除レコードを失効させないでください。同名 Provider は同じ送信先と重複排除ストアを使います。
|
|
179
|
+
- SMTP、Aliyun SMS、既定の API には外部冪等性の保証がありません。sending 開始後の失敗・例外・リース喪失は結果不明として扱い、期限切れは unknown に隔離して自動再送しません。未接続 Provider は保存済み退避設定に従い、不正な保存 JSON は外部呼び出し前に dead へ移します。Console は開発用メタデータのみを出力します。
|
|
180
|
+
- リース更新、owner 条件、キャンセルでは既に開始した外部副作用を取り消せません。汎用の exactly-once は保証しません。
|
|
181
|
+
|
|
182
|
+
### 結果不明の手動確認
|
|
183
|
+
|
|
184
|
+
信頼されたサーバー管理処理で `reach.queue.get(queueId)` を使い、宛先や本文を含まない `pending / processing / sending / unknown / sent / discarded / dead` 状態を確認します。unknown は古い worker を停止・隔離し、外部の配信記録と照合してから sent または discarded に裁定します。UUID・状態の検証と条件付き更新により並行裁定は 1 件だけ成功し、再送はしません。discarded は開始済みリクエストを取り消しません。補充送信は古いリクエストが今後作用しないことを確認した後だけ行ってください。
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
const state = await reach.queue.get(queueId)
|
|
188
|
+
const resolved = await reach.queue.resolveUnknown(queueId, { status: 'sent', messageId: verifiedMessageId })
|
|
189
|
+
if (!resolved.success)
|
|
190
|
+
throw new Error(resolved.error.message)
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### デッドレターの照会と復元
|
|
194
|
+
|
|
195
|
+
信頼されたサーバー管理処理で `reach.queue.list({ status: 'dead', limit: 50, offset: 0 })` を使用します。unknown も照会でき、1 ページ最大 100 件、offset 上限は 1000000 です。get/list は status、attemptCount、maxAttempts、nextAttemptAt、lastFailure、updatedAt のみを返し、宛先・本文・owner 資格情報は含みません。原因を修復後、最新 dead を読み直して条件付きで復元します。
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
const state = await reach.queue.get(queueId)
|
|
199
|
+
if (!state.success)
|
|
200
|
+
throw new Error(state.error.message)
|
|
201
|
+
if (state.data?.status === 'dead' && state.data.updatedAt !== undefined) {
|
|
202
|
+
const restored = await reach.queue.requeueDead(queueId, { expectedUpdatedAt: state.data.updatedAt })
|
|
203
|
+
if (!restored.success)
|
|
204
|
+
throw new Error(restored.error.message)
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
一致する dead バージョンのみ復元し、並行・古い要求は失敗、unknown は復元できません。回数をリセットしますが queueId・保存済み設定・受信側の冪等性要件は保持します。dead は自動処理の停止を意味し、副作用がなかった証明にはなりません。古い冪等 API 要求が遅れて届く可能性があるため、復元前にも受信側の永続的な重複排除を確認してください。修復不能なペイロードは dead に残して調査し、復元だけで内容が直るとは考えないでください。
|
|
209
|
+
|
|
210
|
+
## 本番の共有状態
|
|
211
|
+
|
|
212
|
+
本番は console を拒否し、実際の SMTP・SMS・API サービスを必要とします。ログ書込は配信完了ではありません。冪等でないサービスの未知結果は照合が必要で、exactly-once は保証しません。 NODE_ENV または HAI_CORE_ENV が production の場合、初期化成功前に検証します。明示的な HAI_E2E=1/HAI_E2E_MOCK=1 テストのみ除外します。 [状態・並行制御・復旧範囲(中国語)](../../.agents/instructions/distributed-state.md)。
|
|
213
|
+
|
|
214
|
+
## 認証情報の通知と更新
|
|
215
|
+
|
|
216
|
+
パスワード、OTP、リセットトークンは `reach.send({ ..., sensitive: true, expiresAt, idempotencyKey })` を使います。expiresAt は Unix ミリ秒、操作キーに秘密値を含めません。共有台帳は本文・変数・認証値のハッシュを保存せず、脱機密化した宛先と受領状態のみを保持します。遅延キューに入れず、DND/期限切れは送信せず、不明な配信を自動再送しません。成功時は receiptId を返します。明示した開発用コンソール出力は永続観測から分離し、本番 Console 制限は維持します。通常通知は deferPolicy: reject で二重キューを防げます。
|
|
217
|
+
|
|
218
|
+
`migrateReach({ dryRun: true })` で事前確認し、リポジトリー初期化で対応する移行を実行します。状態と識別子を保持し、旧処理中は unknown、旧ペイロードは一度廃棄し、旧待機ペイロードは discarded にします。更新前は旧書込を停止しバックアップし、古い認証情報を再送しないでください。
|
|
219
|
+
|
|
220
|
+
## API 応答と送信先の境界
|
|
221
|
+
|
|
222
|
+
204/205 を含む空の成功 2xx は受付成功を表し、messageId は任意で、最終配信の証明ではありません。本文がある場合は JSON オブジェクトとし、messageId は255文字以下の任意文字列です。応答上限は64 KiBです。不正応答・タイムアウト・通信失敗を成功扱いにせず、応答本文をログへ出しません。
|
|
223
|
+
|
|
224
|
+
API の `redirect` は既定で `error`、通常通知のみ明示的に `follow` を選択できます。sensitive は常に転送を拒否し、本文や独自ヘッダーを転送しません。`requireHttps:true` は開始URLの HTTPS を必須とし follow と併用できません。省略時は本番の機密通知に HTTPS を要求し、明示 false は運用者が確認した信頼できる内部接続専用です。元の送信先での副作用が不明なら確認が必要で、機密通知は自動再送しません。アプリの許可リストとネットワーク出口制御は引き続き必要です。
|
|
225
|
+
|
|
226
|
+
PostgreSQL/MySQLの取得・更新・最終確認はSQL実行時のDB時計を使い、走査期限もDBから取得します。ノード時差や行ロック待機で期限切れ所有者を復活させません。SQLiteの制御可能な時計は開発・テスト専用です。
|
|
227
|
+
|
|
228
|
+
## 開発とテスト
|
|
229
|
+
|
|
230
|
+
```bash
|
|
231
|
+
pnpm --filter @h-ai/reach test
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
`tests/reach-repository-send-log-databases.test.ts` は Testcontainers で隔離した PostgreSQL 16.15 / MySQL 8.4.11 を起動し、終了後に削除します。利用可能な Docker API が必要です。Podman では `DOCKER_HOST` を互換ソケットに向け、rootless 環境では Testcontainers の要件に従って `TESTCONTAINERS_RYUK_DISABLED=true` を設定してください。
|
|
235
|
+
|
|
236
|
+
## ライセンス
|
|
237
|
+
|
|
238
|
+
Apache-2.0
|