@h-ai/reach 1.0.0-beta.13 → 1.0.0-beta.15

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 CHANGED
@@ -90,14 +90,31 @@ const config = { name: 'dev', type: 'console' }
90
90
 
91
91
  #### Alibaba Cloud SMS
92
92
 
93
- | Field | Type | Required | Default | Description |
94
- | ----------------- | -------- | -------- | ----------------------- | -------------------- |
95
- | `name` | `string` | ✅ | — | Provider name |
96
- | `type` | `string` | ✅ | — | Fixed `'aliyun-sms'` |
97
- | `accessKeyId` | `string` | ✅ | — | AccessKey ID |
98
- | `accessKeySecret` | `string` | ✅ | — | AccessKey Secret |
99
- | `signName` | `string` | ✅ | — | SMS signature |
100
- | `endpoint` | `string` | — | `dysmsapi.aliyuncs.com` | API endpoint |
93
+ | Field | Type | Required | Default | Description |
94
+ | ----------------- | -------- | -------- | ----------------------- | -------------------------- |
95
+ | `name` | `string` | ✅ | — | Provider name |
96
+ | `type` | `string` | ✅ | — | Fixed `'aliyun-sms'` |
97
+ | `accessKeyId` | `string` | ✅ | — | AccessKey ID |
98
+ | `accessKeySecret` | `string` | ✅ | — | AccessKey Secret |
99
+ | `signName` | `string` | ✅ | — | SMS signature |
100
+ | `endpoint` | `string` | — | `dysmsapi.aliyuncs.com` | Direct HTTPS GET endpoint |
101
+ | `gateway` | `object` | — | — | Optional isolation gateway |
102
+
103
+ Add `gateway` to an `aliyun-sms` provider to send a POST request to the full `url`. Alibaba Cloud signature input uses `POST`, the RPC path `/`, and the query parameters. The gateway path, headers, and body apply to the gateway hop.
104
+
105
+ ```yaml
106
+ gateway:
107
+ url: https://sms-gateway.example.com/dx
108
+ headers:
109
+ X-Gateway-Client: delivery
110
+ body: {}
111
+ ```
112
+
113
+ `headers` accepts string values. `body` accepts a JSON object, an array, or raw text. Omit `body` for a request without content. Objects and arrays default to `application/json`, and text defaults to `text/plain; charset=utf-8`. Set `Content-Type` to override the default. HTTP on a trusted private network requires `allowInsecureHttp: true`. The signed query, phone numbers, and template variables travel in plaintext over HTTP. Redact query strings in gateway and proxy access logs.
114
+
115
+ Bind a synchronous or asynchronous server-side function with `reach.init(config, { gatewayHooks: { sms: buildGatewayFields } })`, using the configured provider name as the key. The hook receives `{ method, url, body, signal }`, with the static body already serialized, and can return `{ headers, body }`. Its headers override static values by case-insensitive name, and its body replaces the entire static body. Omitted fields inherit the static values. The HTTP client manages transport headers such as `Host` and `Content-Length`. Treat the signed URL as sensitive. The gateway snapshot in `reach.config` includes the URL and HTTP opt-in flag.
116
+
117
+ The hook and request share a 10-second deadline. Redirects are rejected. HTTP 2xx with Alibaba Cloud `Code: OK` confirms acceptance, and `BizId` can be used to check delivery. Uncertain outcomes follow the existing delivery policy, without falling back to direct delivery.
101
118
 
102
119
  #### API callbacks
103
120
 
package/README.ja.md CHANGED
@@ -90,14 +90,31 @@ const config = { name: 'dev', type: 'console' }
90
90
 
91
91
  #### Alibaba Cloud SMS
92
92
 
93
- | フィールド | 型 | 必須 | 既定値 | 説明 |
94
- | ----------------- | -------- | ---- | ----------------------- | --------------------- |
95
- | `name` | `string` | ✅ | — | Provider 名 |
96
- | `type` | `string` | ✅ | — | 固定値 `'aliyun-sms'` |
97
- | `accessKeyId` | `string` | ✅ | — | AccessKey ID |
98
- | `accessKeySecret` | `string` | ✅ | — | AccessKey Secret |
99
- | `signName` | `string` | ✅ | — | SMS 署名 |
100
- | `endpoint` | `string` | — | `dysmsapi.aliyuncs.com` | API endpoint |
93
+ | フィールド | 型 | 必須 | 既定値 | 説明 |
94
+ | ----------------- | -------- | ---- | ----------------------- | ---------------------------- |
95
+ | `name` | `string` | ✅ | — | Provider 名 |
96
+ | `type` | `string` | ✅ | — | 固定値 `'aliyun-sms'` |
97
+ | `accessKeyId` | `string` | ✅ | — | AccessKey ID |
98
+ | `accessKeySecret` | `string` | ✅ | — | AccessKey Secret |
99
+ | `signName` | `string` | ✅ | — | SMS 署名 |
100
+ | `endpoint` | `string` | — | `dysmsapi.aliyuncs.com` | HTTPS GET の直接接続先 |
101
+ | `gateway` | `object` | — | — | オプションの分離ゲートウェイ |
102
+
103
+ `aliyun-sms` Provider に `gateway` を追加すると、完全な `url` へ POST します。Alibaba Cloud の署名には `POST`、RPC パス `/`、クエリパラメーターを使用します。ゲートウェイのパス、ヘッダー、ボディはゲートウェイへのリクエストに適用されます。
104
+
105
+ ```yaml
106
+ gateway:
107
+ url: https://sms-gateway.example.com/dx
108
+ headers:
109
+ X-Gateway-Client: delivery
110
+ body: {}
111
+ ```
112
+
113
+ `headers` の値は文字列です。`body` には JSON オブジェクト、配列、またはテキストを指定でき、省略時はボディなしで送信します。既定の `Content-Type` はオブジェクトと配列が `application/json`、テキストが `text/plain; charset=utf-8` です。信頼できる内部ネットワークで HTTP を使う場合は `allowInsecureHttp: true` が必要です。HTTP では署名済みクエリ、電話番号、テンプレート変数が平文で送られるため、ゲートウェイとプロキシのアクセスログでクエリをマスクしてください。
114
+
115
+ 同期または非同期の関数は、サーバー側で `reach.init(config, { gatewayHooks: { sms: buildGatewayFields } })` のように Provider 名へ紐付けます。関数は `{ method, url, body, signal }` を受け取り、`{ headers, body }` を返せます。入力の `body` はシリアライズ済みです。ヘッダー名は大文字・小文字を区別せず上書きし、ボディは全体を置き換えます。省略した項目には静的設定を使います。`Host` や `Content-Length` は HTTP クライアントが管理します。署名済み URL は機密情報として扱います。`reach.config` のゲートウェイ情報には URL と HTTP の許可フラグが含まれます。
116
+
117
+ 関数と HTTP リクエストの合計期限は 10 秒で、リダイレクトは拒否します。HTTP 2xx かつ Alibaba Cloud の `Code: OK` で受理を確認し、`BizId` で配信結果を照会できます。結果が不明な場合は既存の配信ポリシーに従い、直接送信への切り替えは行いません。
101
118
 
102
119
  #### API 通知
103
120
 
package/README.md CHANGED
@@ -97,7 +97,24 @@ const config = { name: 'dev', type: 'console' }
97
97
  | `accessKeyId` | `string` | ✅ | — | AccessKey ID |
98
98
  | `accessKeySecret` | `string` | ✅ | — | AccessKey Secret |
99
99
  | `signName` | `string` | ✅ | — | 短信签名 |
100
- | `endpoint` | `string` | — | `dysmsapi.aliyuncs.com` | API 端点 |
100
+ | `endpoint` | `string` | — | `dysmsapi.aliyuncs.com` | HTTPS GET 直连端点 |
101
+ | `gateway` | `object` | — | — | 可选的隔离网关 |
102
+
103
+ 在 `aliyun-sms` Provider 下添加 `gateway` 后,Reach 向完整 `url` 发送 POST。阿里云签名使用 `POST`、RPC 路径 `/` 和查询参数。网关路径、请求头和请求体仅用于网关这一跳。
104
+
105
+ ```yaml
106
+ gateway:
107
+ url: https://sms-gateway.example.com/dx
108
+ headers:
109
+ X-Gateway-Client: delivery
110
+ body: {}
111
+ ```
112
+
113
+ `headers` 接受字符串键值,`body` 可为 JSON 对象、数组或原始文本。省略 `body` 时发送无正文请求。对象和数组默认为 `application/json`,文本默认为 `text/plain; charset=utf-8`,可以用 `Content-Type` 覆盖。受信内网使用 HTTP 时须显式设 `allowInsecureHttp: true`。HTTP 下签名查询串、手机号及模板变量是明文,网关和代理的访问日志应对查询串脱敏。
114
+
115
+ 在服务端通过 `reach.init(config, { gatewayHooks: { sms: buildGatewayFields } })` 绑定同步或异步函数,其中 `sms` 是启用网关的 Provider 名称。函数接收 `{ method, url, body, signal }`,可返回 `{ headers, body }`。`body` 入参是已序列化的静态正文。函数返回的头按名称忽略大小写覆盖静态头,返回的正文整段替换。省略字段时使用静态配置。`Host`、`Content-Length` 等传输头由客户端管理。函数接收的已签名 URL 仅供服务端构造请求,`reach.config` 的网关快照仅展示地址和 HTTP 开关。
116
+
117
+ 函数和 HTTP 请求共用 10 秒期限,禁止重定向。HTTP 2xx 且阿里云返回 `Code: OK` 表示受理,`BizId` 可用于查询送达结果。发送结果不明时按原投递策略处理,网关分支不会自动回退直连。
101
118
 
102
119
  #### API 回调
103
120
 
package/dist/index.d.ts CHANGED
@@ -59,6 +59,12 @@ declare const AliyunSmsProviderConfigSchema: z.ZodObject<{
59
59
  accessKeySecret: z.ZodString;
60
60
  signName: z.ZodString;
61
61
  endpoint: z.ZodDefault<z.ZodString>;
62
+ gateway: z.ZodOptional<z.ZodObject<{
63
+ headers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
64
+ body: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodJSONSchema>, z.ZodRecord<z.ZodString, z.ZodJSONSchema>]>>;
65
+ url: z.ZodURL;
66
+ allowInsecureHttp: z.ZodDefault<z.ZodBoolean>;
67
+ }, z.core.$strict>>;
62
68
  }, z.core.$strip>;
63
69
  /**
64
70
  * HTTP回调配置;idempotent默认false,只有接收端按稳定Idempotency-Key原子持久去重并覆盖旧请求迟到窗口才允许true。
@@ -115,6 +121,12 @@ declare const ProviderConfigSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
115
121
  accessKeySecret: z.ZodString;
116
122
  signName: z.ZodString;
117
123
  endpoint: z.ZodDefault<z.ZodString>;
124
+ gateway: z.ZodOptional<z.ZodObject<{
125
+ headers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
126
+ body: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodJSONSchema>, z.ZodRecord<z.ZodString, z.ZodJSONSchema>]>>;
127
+ url: z.ZodURL;
128
+ allowInsecureHttp: z.ZodDefault<z.ZodBoolean>;
129
+ }, z.core.$strict>>;
118
130
  }, z.core.$strip>, z.ZodObject<{
119
131
  name: z.ZodString;
120
132
  type: z.ZodLiteral<"api">;
@@ -344,6 +356,12 @@ declare const ReachConfigSchema: z.ZodObject<{
344
356
  accessKeySecret: z.ZodString;
345
357
  signName: z.ZodString;
346
358
  endpoint: z.ZodDefault<z.ZodString>;
359
+ gateway: z.ZodOptional<z.ZodObject<{
360
+ headers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
361
+ body: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodJSONSchema>, z.ZodRecord<z.ZodString, z.ZodJSONSchema>]>>;
362
+ url: z.ZodURL;
363
+ allowInsecureHttp: z.ZodDefault<z.ZodBoolean>;
364
+ }, z.core.$strict>>;
347
365
  }, z.core.$strip>, z.ZodObject<{
348
366
  name: z.ZodString;
349
367
  type: z.ZodLiteral<"api">;
@@ -440,6 +458,38 @@ declare const HaiReachError: {
440
458
  * ```
441
459
  */
442
460
  type ReachChannel = 'email' | 'sms' | 'api';
461
+ /**
462
+ * 发送前为网关提供请求头或替换请求体。URL 已包含签名和可能敏感的查询数据,函数必须在可信服务端运行。
463
+ * 返回 undefined 保留静态配置;字符串 body 按原样发送。signal 覆盖函数执行与 HTTP 请求的共同期限。
464
+ *
465
+ * @example
466
+ * ```ts
467
+ * import type { ReachGatewayHook } from '@h-ai/reach'
468
+ * const hook: ReachGatewayHook = ({ body, signal }) => {
469
+ * signal.throwIfAborted()
470
+ * return { headers: { 'X-Client': 'delivery' }, body: body ?? '{}' }
471
+ * }
472
+ * ```
473
+ */
474
+ type ReachGatewayHook = (request: Readonly<{
475
+ method: 'POST';
476
+ url: string;
477
+ body: string | undefined;
478
+ signal: AbortSignal;
479
+ }>) => Pick<NonNullable<AliyunSmsProviderConfig['gateway']>, 'headers' | 'body'> | void | Promise<Pick<NonNullable<AliyunSmsProviderConfig['gateway']>, 'headers' | 'body'> | void>;
480
+ /**
481
+ * 服务端运行时注入选项,与可序列化的配置分离。
482
+ *
483
+ * @example
484
+ * ```ts
485
+ * import type { ReachInitOptions } from '@h-ai/reach'
486
+ * const options: ReachInitOptions = { gatewayHooks: { sms: () => ({ headers: { 'X-Client': 'delivery' } }) } }
487
+ * ```
488
+ */
489
+ interface ReachInitOptions {
490
+ /** 以已配置网关的 Provider 名称绑定自定义函数。 */
491
+ gatewayHooks?: Readonly<Record<string, ReachGatewayHook>>;
492
+ }
443
493
  /**
444
494
  * 直接发送必须指定Provider名和内容;模板发送可由持久模板推导路由,vars做文本替换。
445
495
  * 队列幂等键固定为持久queueId;直接调用的业务重试由应用提供稳定idempotencyKey并核验接收端契约。
@@ -1215,6 +1265,7 @@ interface ReachFunctions extends SendOperations {
1215
1265
  /**
1216
1266
  * 初始化触达模块(注册多个 Provider)
1217
1267
  * @param config - 实际操作输入,按本方法约束校验。
1268
+ * @param options - 可选服务端运行时网关函数,按 Provider 名称绑定。
1218
1269
  * @returns HaiResult;先检查success,条件提交失败不能伪装成功。
1219
1270
  *
1220
1271
  * @example
@@ -1244,7 +1295,7 @@ interface ReachFunctions extends SendOperations {
1244
1295
  * }
1245
1296
  * ```
1246
1297
  */
1247
- init: (config: ReachConfigInput) => Promise<HaiResult<void>>;
1298
+ init: (config: ReachConfigInput, options?: ReachInitOptions) => Promise<HaiResult<void>>;
1248
1299
  /** 当前脱敏后的配置快照(未初始化时为 null) */
1249
1300
  readonly config: ReachConfig | null;
1250
1301
  /** 是否已初始化 */
@@ -1338,4 +1389,4 @@ declare function migrateReach(options?: {
1338
1389
  dryRun?: boolean;
1339
1390
  }, db?: ReldbFunctions): Promise<_h_ai_core.HaiResult<_h_ai_reldb.SchemaMigrationPlan>>;
1340
1391
 
1341
- export { type AliyunSmsProviderConfig, AliyunSmsProviderConfigSchema, type ApiProviderConfig, ApiProviderConfigSchema, type ConsoleProviderConfig, ConsoleProviderConfigSchema, type DndConfig, DndConfigSchema, HaiReachError, type ProviderConfig, ProviderConfigSchema, type ReachChannel, type ReachConfig, type ReachConfigInput, ReachConfigSchema, type ReachDeadRequeue, ReachDeadRequeueSchema, type ReachFunctions, type ReachMessage, type ReachQueueConfig, ReachQueueConfigSchema, type ReachQueueEntry, type ReachQueueFailure, ReachQueueIdSchema, type ReachQueueListOptions, ReachQueueListSchema, type ReachQueueOperations, type ReachQueueStatus, type ReachTemplate, type ReachTemplateRegistry, type ReachUnknownResolution, ReachUnknownResolutionSchema, type RenderedTemplate, type SendOperations, type SendResult, type SmtpProviderConfig, SmtpProviderConfigSchema, type TemplateConfig, TemplateConfigSchema, migrateReach, reach };
1392
+ export { type AliyunSmsProviderConfig, AliyunSmsProviderConfigSchema, type ApiProviderConfig, ApiProviderConfigSchema, type ConsoleProviderConfig, ConsoleProviderConfigSchema, type DndConfig, DndConfigSchema, HaiReachError, type ProviderConfig, ProviderConfigSchema, type ReachChannel, type ReachConfig, type ReachConfigInput, ReachConfigSchema, type ReachDeadRequeue, ReachDeadRequeueSchema, type ReachFunctions, type ReachGatewayHook, type ReachInitOptions, type ReachMessage, type ReachQueueConfig, ReachQueueConfigSchema, type ReachQueueEntry, type ReachQueueFailure, ReachQueueIdSchema, type ReachQueueListOptions, ReachQueueListSchema, type ReachQueueOperations, type ReachQueueStatus, type ReachTemplate, type ReachTemplateRegistry, type ReachUnknownResolution, ReachUnknownResolutionSchema, type RenderedTemplate, type SendOperations, type SendResult, type SmtpProviderConfig, SmtpProviderConfigSchema, type TemplateConfig, TemplateConfigSchema, migrateReach, reach };
package/dist/index.js CHANGED
@@ -3,6 +3,7 @@ import { core, err, ok } from '@h-ai/core';
3
3
  import process from 'process';
4
4
  import { reldb, migrateSchema, schemaColumns, BaseReldbCrudRepository } from '@h-ai/reldb';
5
5
  import { randomUUID, createHash, createHmac } from 'crypto';
6
+ import { addAbortListener } from 'events';
6
7
  import { createRequire } from 'module';
7
8
  import { cache } from '@h-ai/cache';
8
9
 
@@ -30,6 +31,11 @@ var en_US_default = {
30
31
  reach_queueInputInvalid: "Invalid queue receipt or manual resolution parameters",
31
32
  reach_deliveryUnknown: "The external delivery outcome is unknown; manual review is required and automatic redelivery has stopped",
32
33
  reach_smsSendFailed: "SMS send failed: {error}",
34
+ reach_smsGatewayFailed: "The SMS gateway request failed or timed out. Delivery is unconfirmed",
35
+ reach_gatewayUrlInvalid: "Use a full HTTPS gateway URL without credentials, a query, or a fragment. For HTTP on a trusted private network, explicitly enable allowInsecureHttp",
36
+ reach_gatewayHeadersInvalid: "Gateway headers are invalid or include a header managed by the HTTP client",
37
+ reach_gatewayBodyInvalid: "The gateway request body must be a JSON object, an array, or text",
38
+ reach_gatewayHookInvalid: "Gateway hooks must be bound to an SMS provider with a gateway configured",
33
39
  reach_emailSendFailed: "Email send failed: {error}",
34
40
  reach_apiSendFailed: "API callback send failed: {error}",
35
41
  reach_configError: "Reach config validation failed: {error}",
@@ -83,6 +89,11 @@ var ja_JP_default = {
83
89
  reach_queueInputInvalid: "\u30AD\u30E5\u30FC\u306E\u53D7\u9818\u8A18\u9332\u307E\u305F\u306F\u624B\u52D5\u89E3\u6C7A\u306E\u30D1\u30E9\u30E1\u30FC\u30BF\u30FC\u304C\u7121\u52B9\u3067\u3059",
84
90
  reach_deliveryUnknown: "\u5916\u90E8\u3078\u306E\u914D\u4FE1\u7D50\u679C\u306F\u4E0D\u660E\u3067\u3059\u3002\u624B\u52D5\u78BA\u8A8D\u304C\u5FC5\u8981\u306A\u305F\u3081\u3001\u81EA\u52D5\u518D\u914D\u4FE1\u3092\u505C\u6B62\u3057\u307E\u3057\u305F",
85
91
  reach_smsSendFailed: "SMS\u306E\u9001\u4FE1\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {error}",
92
+ reach_smsGatewayFailed: "SMS \u30B2\u30FC\u30C8\u30A6\u30A7\u30A4\u3078\u306E\u30EA\u30AF\u30A8\u30B9\u30C8\u304C\u5931\u6557\u307E\u305F\u306F\u30BF\u30A4\u30E0\u30A2\u30A6\u30C8\u3057\u3001\u914D\u4FE1\u3092\u78BA\u8A8D\u3067\u304D\u307E\u305B\u3093\u3067\u3057\u305F",
93
+ reach_gatewayUrlInvalid: "\u30B2\u30FC\u30C8\u30A6\u30A7\u30A4\u306B\u306F\u8A8D\u8A3C\u60C5\u5831\u3001\u30AF\u30A8\u30EA\u3001\u30D5\u30E9\u30B0\u30E1\u30F3\u30C8\u3092\u542B\u307E\u306A\u3044\u5B8C\u5168\u306A HTTPS URL \u3092\u6307\u5B9A\u3057\u3066\u304F\u3060\u3055\u3044\u3002\u4FE1\u983C\u3067\u304D\u308B\u5185\u90E8\u30CD\u30C3\u30C8\u30EF\u30FC\u30AF\u3067 HTTP \u3092\u4F7F\u7528\u3059\u308B\u5834\u5408\u306F allowInsecureHttp \u3092\u660E\u793A\u7684\u306B\u6709\u52B9\u306B\u3057\u3066\u304F\u3060\u3055\u3044",
94
+ reach_gatewayHeadersInvalid: "\u30B2\u30FC\u30C8\u30A6\u30A7\u30A4\u306E\u30EA\u30AF\u30A8\u30B9\u30C8\u30D8\u30C3\u30C0\u30FC\u304C\u7121\u52B9\u304B\u3001HTTP \u30AF\u30E9\u30A4\u30A2\u30F3\u30C8\u304C\u7BA1\u7406\u3059\u308B\u30D8\u30C3\u30C0\u30FC\u304C\u542B\u307E\u308C\u3066\u3044\u307E\u3059",
95
+ reach_gatewayBodyInvalid: "\u30B2\u30FC\u30C8\u30A6\u30A7\u30A4\u306E\u30EA\u30AF\u30A8\u30B9\u30C8\u30DC\u30C7\u30A3\u306B\u306F JSON \u30AA\u30D6\u30B8\u30A7\u30AF\u30C8\u3001\u914D\u5217\u3001\u307E\u305F\u306F\u30C6\u30AD\u30B9\u30C8\u3092\u6307\u5B9A\u3057\u3066\u304F\u3060\u3055\u3044",
96
+ reach_gatewayHookInvalid: "\u30B2\u30FC\u30C8\u30A6\u30A7\u30A4\u306E\u30D5\u30C3\u30AF\u306F\u3001\u30B2\u30FC\u30C8\u30A6\u30A7\u30A4\u3092\u8A2D\u5B9A\u3057\u305F SMS Provider \u306B\u5272\u308A\u5F53\u3066\u3066\u304F\u3060\u3055\u3044",
86
97
  reach_emailSendFailed: "\u30E1\u30FC\u30EB\u306E\u9001\u4FE1\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {error}",
87
98
  reach_apiSendFailed: "API\u30B3\u30FC\u30EB\u30D0\u30C3\u30AF\u306E\u9001\u4FE1\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {error}",
88
99
  reach_configError: "\u901A\u77E5\u8A2D\u5B9A\u306E\u691C\u8A3C\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {error}",
@@ -136,6 +147,11 @@ var zh_CN_default = {
136
147
  reach_queueInputInvalid: "\u961F\u5217\u56DE\u6267\u6216\u4EBA\u5DE5\u88C1\u5B9A\u53C2\u6570\u65E0\u6548",
137
148
  reach_deliveryUnknown: "\u5916\u90E8\u6295\u9012\u7ED3\u679C\u672A\u77E5\uFF0C\u9700\u8981\u4EBA\u5DE5\u6838\u5BF9\uFF0C\u5DF2\u505C\u6B62\u81EA\u52A8\u91CD\u53D1",
138
149
  reach_smsSendFailed: "\u77ED\u4FE1\u53D1\u9001\u5931\u8D25: {error}",
150
+ reach_smsGatewayFailed: "\u77ED\u4FE1\u7F51\u5173\u8BF7\u6C42\u5931\u8D25\u6216\u8D85\u65F6\uFF0C\u6295\u9012\u7ED3\u679C\u672A\u786E\u8BA4",
151
+ reach_gatewayUrlInvalid: "\u7F51\u5173\u987B\u4F7F\u7528\u5B8C\u6574\u7684 HTTPS URL\uFF0C\u4E14\u4E0D\u80FD\u5305\u542B\u51ED\u636E\u3001\u67E5\u8BE2\u53C2\u6570\u6216\u7247\u6BB5\u3002\u53D7\u4FE1\u5185\u7F51\u4F7F\u7528 HTTP \u65F6\u987B\u663E\u5F0F\u542F\u7528 allowInsecureHttp",
152
+ reach_gatewayHeadersInvalid: "\u7F51\u5173\u8BF7\u6C42\u5934\u683C\u5F0F\u65E0\u6548\uFF0C\u6216\u5305\u542B\u7531 HTTP \u5BA2\u6237\u7AEF\u7BA1\u7406\u7684\u8BF7\u6C42\u5934",
153
+ reach_gatewayBodyInvalid: "\u7F51\u5173\u8BF7\u6C42\u4F53\u5FC5\u987B\u662F JSON \u5BF9\u8C61\u3001\u6570\u7EC4\u6216\u6587\u672C",
154
+ reach_gatewayHookInvalid: "\u7F51\u5173\u51FD\u6570\u5FC5\u987B\u7ED1\u5B9A\u5230\u542F\u7528\u4E86\u7F51\u5173\u7684\u77ED\u4FE1 Provider",
139
155
  reach_emailSendFailed: "\u90AE\u4EF6\u53D1\u9001\u5931\u8D25: {error}",
140
156
  reach_apiSendFailed: "API \u56DE\u8C03\u53D1\u9001\u5931\u8D25: {error}",
141
157
  reach_configError: "\u89E6\u8FBE\u914D\u7F6E\u6821\u9A8C\u5931\u8D25\uFF1A{error}",
@@ -174,6 +190,33 @@ var reachM = core.i18n.createMessageGetter({
174
190
  "ja-JP": ja_JP_default
175
191
  });
176
192
 
193
+ // src/reach-gateway-config.ts
194
+ var transportHeader = /^(?:connection|content-length|host|keep-alive|proxy-connection|te|trailer|transfer-encoding|upgrade)$/i;
195
+ var jsonValue = z.json();
196
+ var gatewayFields = {
197
+ /** 网关自定义请求头;连接与消息长度相关的头由 HTTP 客户端管理。 */
198
+ headers: z.record(z.string(), z.string()).refine((headers) => {
199
+ try {
200
+ return [...new Headers(headers).keys()].every((name) => !transportHeader.test(name));
201
+ } catch {
202
+ return false;
203
+ }
204
+ }, reachM("reach_gatewayHeadersInvalid")).optional(),
205
+ /** 网关请求体;对象和数组序列化为 JSON,字符串原样发送。省略时不发请求体。 */
206
+ body: z.union([z.string(), z.array(jsonValue), z.record(z.string(), jsonValue)], reachM("reach_gatewayBodyInvalid")).optional()
207
+ };
208
+ var ReachGatewayFieldsSchema = z.strictObject(gatewayFields);
209
+ var ReachGatewayConfigSchema = z.strictObject({
210
+ /** 网关的完整 HTTP(S) URL;包含路径,不允许凭据、查询参数或片段。 */
211
+ url: z.url(reachM("reach_gatewayUrlInvalid")),
212
+ /** 仅在受信内网显式允许明文 HTTP。 */
213
+ allowInsecureHttp: z.boolean().default(false),
214
+ ...gatewayFields
215
+ }).refine(({ url, allowInsecureHttp }) => {
216
+ const target = URL.parse(url);
217
+ return target !== null && /^https?:\/\//i.test(url) && !/[?#]/.test(url) && !target.username && !target.password && (target.protocol === "https:" || target.protocol === "http:" && allowInsecureHttp);
218
+ }, { path: ["url"], message: reachM("reach_gatewayUrlInvalid") });
219
+
177
220
  // src/reach-config.ts
178
221
  var ConsoleProviderConfigSchema = z.object({
179
222
  /** Provider 唯一名称 */
@@ -207,8 +250,10 @@ var AliyunSmsProviderConfigSchema = z.object({
207
250
  accessKeySecret: z.string().min(1, reachM("reach_config_accessKeySecretRequired")),
208
251
  /** 短信签名 */
209
252
  signName: z.string().min(1, reachM("reach_config_signNameRequired")),
210
- /** API 端点(默认 dysmsapi.aliyuncs.com) */
211
- endpoint: z.string().default("dysmsapi.aliyuncs.com")
253
+ /** 直连 API 端点(默认 dysmsapi.aliyuncs.com);配置网关时使用网关 URL。 */
254
+ endpoint: z.string().default("dysmsapi.aliyuncs.com"),
255
+ /** 可选的短信隔离网关,以 POST 请求转发已签名的查询参数。 */
256
+ gateway: ReachGatewayConfigSchema.optional()
212
257
  });
213
258
  var ApiProviderConfigSchema = z.object({
214
259
  /** Provider 唯一名称 */
@@ -308,6 +353,9 @@ var HaiReachError = core.error.buildHaiErrorsDef("reach", ReachErrorInfo);
308
353
  // src/providers/reach-provider-aliyun-sms.ts
309
354
  var logger = core.logger.child({ module: "reach", scope: "provider-aliyun-sms" });
310
355
  var ISO_MILLISECONDS_SUFFIX_REGEX = /\.\d{3}Z$/;
356
+ var RFC3986_RESERVED_CHARACTERS_REGEX = /[!'()*]/g;
357
+ var GATEWAY_TIMEOUT_MS = 1e4;
358
+ var GATEWAY_RESPONSE_LIMIT_BYTES = 64 * 1024;
311
359
  function toReachError(error) {
312
360
  return {
313
361
  code: HaiReachError.SEND_FAILED.code,
@@ -318,7 +366,7 @@ function toReachError(error) {
318
366
  };
319
367
  }
320
368
  function percentEncode(str) {
321
- return encodeURIComponent(str).replace(/\+/g, "%20").replace(/\*/g, "%2A").replace(/~/g, "%7E");
369
+ return encodeURIComponent(str).replace(RFC3986_RESERVED_CHARACTERS_REGEX, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
322
370
  }
323
371
  function signRequest(params, accessKeySecret, method) {
324
372
  const sortedKeys = Object.keys(params).sort();
@@ -328,7 +376,58 @@ function signRequest(params, accessKeySecret, method) {
328
376
  hmac.update(stringToSign);
329
377
  return hmac.digest("base64");
330
378
  }
331
- function createAliyunSmsProvider() {
379
+ async function prepareGatewayRequest(gateway, url, signal, hook) {
380
+ let body = gateway.body;
381
+ const headers = new Headers();
382
+ for (const [name, value] of Object.entries(gateway.headers ?? {}))
383
+ headers.set(name, value);
384
+ if (hook) {
385
+ signal.throwIfAborted();
386
+ let listener;
387
+ const aborted = new Promise((_resolve, reject) => {
388
+ listener = addAbortListener(signal, () => reject(signal.reason));
389
+ });
390
+ try {
391
+ const output = await Promise.race([
392
+ Promise.resolve().then(() => hook({ method: "POST", url, body: typeof body === "string" ? body : JSON.stringify(body), signal })),
393
+ aborted
394
+ ]);
395
+ const patch = ReachGatewayFieldsSchema.parse(output === void 0 ? {} : output);
396
+ body = patch.body ?? body;
397
+ for (const [name, value] of Object.entries(patch.headers ?? {}))
398
+ headers.set(name, value);
399
+ } finally {
400
+ listener?.[Symbol.dispose]();
401
+ }
402
+ }
403
+ if (body !== void 0 && !headers.has("Content-Type"))
404
+ headers.set("Content-Type", typeof body === "string" ? "text/plain; charset=utf-8" : "application/json");
405
+ return { method: "POST", headers, body: typeof body === "string" ? body : JSON.stringify(body), redirect: "error", signal };
406
+ }
407
+ async function readGatewayResponse(response) {
408
+ if (response.ok && response.body) {
409
+ let bytes = 0;
410
+ const stream = response.body.pipeThrough(new TransformStream({
411
+ transform(chunk, controller) {
412
+ bytes += chunk.byteLength;
413
+ if (bytes > GATEWAY_RESPONSE_LIMIT_BYTES)
414
+ throw new Error("SMS gateway response exceeded the size limit");
415
+ controller.enqueue(chunk);
416
+ }
417
+ }));
418
+ const body = await new Response(stream).json();
419
+ if (body && typeof body === "object" && "Code" in body && body.Code === "OK") {
420
+ const messageId = "BizId" in body ? body.BizId : void 0;
421
+ if (messageId === void 0 || typeof messageId === "string" && messageId.length <= 255)
422
+ return ok({ success: true, messageId });
423
+ }
424
+ } else {
425
+ await response.body?.cancel();
426
+ }
427
+ logger.warn("SMS gateway returned an unsuccessful receipt", { status: response.status });
428
+ return err(HaiReachError.SEND_FAILED, reachM("reach_smsGatewayFailed"));
429
+ }
430
+ function createAliyunSmsProvider(gatewayHook) {
332
431
  let smsConfig = null;
333
432
  return {
334
433
  name: "aliyun-sms",
@@ -341,7 +440,7 @@ function createAliyunSmsProvider() {
341
440
  );
342
441
  }
343
442
  smsConfig = config;
344
- logger.info("Aliyun SMS provider connected", { endpoint: config.endpoint });
443
+ logger.info("Aliyun SMS provider connected", config.gateway ? { gateway: true } : { endpoint: config.endpoint });
345
444
  return ok(void 0);
346
445
  },
347
446
  async close() {
@@ -352,21 +451,23 @@ function createAliyunSmsProvider() {
352
451
  return smsConfig !== null;
353
452
  },
354
453
  async send(message) {
355
- if (!smsConfig) {
454
+ const config = smsConfig;
455
+ if (!config) {
356
456
  return err(
357
457
  HaiReachError.NOT_INITIALIZED,
358
458
  reachM("reach_notInitialized")
359
459
  );
360
460
  }
361
461
  const templateCode = message.extra?.templateCode;
362
- logger.debug("Sending SMS", { to: message.to, templateCode });
462
+ if (!config.gateway)
463
+ logger.debug("Sending SMS", { to: message.to, templateCode });
363
464
  try {
364
465
  const params = {
365
- AccessKeyId: smsConfig.accessKeyId,
466
+ AccessKeyId: config.accessKeyId,
366
467
  Action: "SendSms",
367
468
  Format: "JSON",
368
469
  PhoneNumbers: message.to,
369
- SignName: smsConfig.signName,
470
+ SignName: config.signName,
370
471
  SignatureMethod: "HMAC-SHA1",
371
472
  SignatureNonce: randomUUID(),
372
473
  SignatureVersion: "1.0",
@@ -377,13 +478,25 @@ function createAliyunSmsProvider() {
377
478
  if (message.vars) {
378
479
  params.TemplateParam = JSON.stringify(message.vars);
379
480
  }
380
- params.Signature = signRequest(params, smsConfig.accessKeySecret, "GET");
481
+ params.Signature = signRequest(params, config.accessKeySecret, config.gateway ? "POST" : "GET");
381
482
  const queryString = Object.entries(params).map(([k, v]) => `${percentEncode(k)}=${percentEncode(v)}`).join("&");
382
- const url = `https://${smsConfig.endpoint}/?${queryString}`;
483
+ if (config.gateway) {
484
+ const deadline = performance.now() + GATEWAY_TIMEOUT_MS;
485
+ const signal = AbortSignal.timeout(GATEWAY_TIMEOUT_MS);
486
+ const url2 = `${new URL(config.gateway.url).href}?${queryString}`;
487
+ const request = await prepareGatewayRequest(config.gateway, url2, signal, gatewayHook);
488
+ signal.throwIfAborted();
489
+ if (smsConfig !== config || performance.now() >= deadline)
490
+ return err(HaiReachError.SEND_FAILED, reachM("reach_smsGatewayFailed"));
491
+ if (message.sensitive && message.expiresAt !== void 0 && Date.now() >= message.expiresAt)
492
+ return err(HaiReachError.MESSAGE_EXPIRED, reachM("reach_messageExpired"));
493
+ return await readGatewayResponse(await fetch(url2, request));
494
+ }
495
+ const url = `https://${config.endpoint}/?${queryString}`;
383
496
  const response = await fetch(url);
384
497
  const body = await response.json();
385
498
  if (body.Code !== "OK") {
386
- logger.warn("SMS send returned non-OK status", { code: body.Code, message: body.Message });
499
+ logger.warn("SMS send returned non-OK status", { code: body.Code });
387
500
  return err(
388
501
  HaiReachError.SEND_FAILED,
389
502
  reachM("reach_smsSendFailed", { params: { error: body.Message } })
@@ -392,6 +505,10 @@ function createAliyunSmsProvider() {
392
505
  logger.info("SMS sent", { to: message.to, bizId: body.BizId });
393
506
  return ok({ success: true, messageId: body.BizId });
394
507
  } catch (error) {
508
+ if (config.gateway) {
509
+ logger.warn("SMS gateway request failed");
510
+ return err(HaiReachError.SEND_FAILED, reachM("reach_smsGatewayFailed"));
511
+ }
395
512
  logger.error("SMS send failed", { to: message.to, error });
396
513
  return err(toReachError(error));
397
514
  }
@@ -1970,14 +2087,14 @@ var templateRepo = null;
1970
2087
  var initInProgress = false;
1971
2088
  var initCompletion = null;
1972
2089
  var closeInProgress = null;
1973
- function createProvider(config) {
2090
+ function createProvider(config, gatewayHook) {
1974
2091
  switch (config.type) {
1975
2092
  case "console":
1976
2093
  return createConsoleProvider();
1977
2094
  case "smtp":
1978
2095
  return createSmtpProvider();
1979
2096
  case "aliyun-sms":
1980
- return createAliyunSmsProvider();
2097
+ return createAliyunSmsProvider(gatewayHook);
1981
2098
  case "api":
1982
2099
  return createApiProvider();
1983
2100
  }
@@ -2047,7 +2164,7 @@ async function doClose() {
2047
2164
  resetSendState();
2048
2165
  logger7.info("Reach module closed");
2049
2166
  }
2050
- async function doInit(config) {
2167
+ async function doInit(config, options) {
2051
2168
  if (providers.size > 0) {
2052
2169
  logger7.warn("Reach module is already initialized, reinitializing");
2053
2170
  await doClose();
@@ -2063,6 +2180,11 @@ async function doInit(config) {
2063
2180
  );
2064
2181
  }
2065
2182
  const parsed = parseResult.data;
2183
+ const gatewayHooks = new Map(Object.entries(options?.gatewayHooks ?? {}));
2184
+ for (const [name, hook] of gatewayHooks) {
2185
+ if (typeof hook !== "function" || !parsed.providers.some((provider) => provider.name === name && provider.type === "aliyun-sms" && provider.gateway))
2186
+ return err(HaiReachError.CONFIG_ERROR, reachM("reach_gatewayHookInvalid"));
2187
+ }
2066
2188
  if (core.module.requiresSharedState(process.env) && parsed.providers.some((provider) => provider.type === "console"))
2067
2189
  return err(HaiReachError.CONFIG_ERROR, reachM("reach_productionProviderRequired"));
2068
2190
  try {
@@ -2077,7 +2199,7 @@ async function doInit(config) {
2077
2199
  }
2078
2200
  const newProviders = /* @__PURE__ */ new Map();
2079
2201
  for (const providerConfig of parsed.providers) {
2080
- const provider = createProvider(providerConfig);
2202
+ const provider = createProvider(providerConfig, gatewayHooks.get(providerConfig.name));
2081
2203
  const connectResult = await provider.connect(providerConfig);
2082
2204
  if (!connectResult.success) {
2083
2205
  logger7.error("Provider initialization failed", {
@@ -2193,7 +2315,7 @@ var reach = {
2193
2315
  return sendLogRepo.resolveUnknown(queueId, parsed.data);
2194
2316
  }
2195
2317
  },
2196
- async init(config) {
2318
+ async init(config, options) {
2197
2319
  if (initInProgress || closeInProgress) {
2198
2320
  logger7.warn("Reach module init already in progress, skipping");
2199
2321
  return err(
@@ -2203,7 +2325,7 @@ var reach = {
2203
2325
  }
2204
2326
  initInProgress = true;
2205
2327
  try {
2206
- initCompletion = doInit(config);
2328
+ initCompletion = doInit(config, options);
2207
2329
  return await initCompletion;
2208
2330
  } finally {
2209
2331
  initInProgress = false;
@@ -2221,7 +2343,15 @@ var reach = {
2221
2343
  return templateRegistry;
2222
2344
  },
2223
2345
  get config() {
2224
- return currentConfig ? core.sanitize.sanitizeSensitiveFields(currentConfig) : null;
2346
+ if (!currentConfig)
2347
+ return null;
2348
+ const safeProviders = currentConfig.providers.map((provider) => {
2349
+ if (provider.type !== "aliyun-sms" || !provider.gateway)
2350
+ return provider;
2351
+ const { url, allowInsecureHttp } = provider.gateway;
2352
+ return { ...provider, gateway: { url, allowInsecureHttp } };
2353
+ });
2354
+ return core.sanitize.sanitizeSensitiveFields({ ...currentConfig, providers: safeProviders });
2225
2355
  },
2226
2356
  get isInitialized() {
2227
2357
  return providers.size > 0;