watahub 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +332 -2
- package/dist/clock.d.ts +3 -0
- package/dist/clock.js +1 -0
- package/dist/config.d.ts +31 -0
- package/dist/config.js +26 -0
- package/dist/deps.d.ts +67 -0
- package/dist/deps.js +38 -0
- package/dist/http/auth.d.ts +23 -0
- package/dist/http/auth.js +61 -0
- package/dist/http/background.d.ts +14 -0
- package/dist/http/background.js +40 -0
- package/dist/http/body.d.ts +10 -0
- package/dist/http/body.js +22 -0
- package/dist/http/cors.d.ts +12 -0
- package/dist/http/cors.js +37 -0
- package/dist/http/env-guard.d.ts +7 -0
- package/dist/http/env-guard.js +16 -0
- package/dist/http/errors.d.ts +23 -0
- package/dist/http/errors.js +35 -0
- package/dist/http/headers.d.ts +8 -0
- package/dist/http/headers.js +14 -0
- package/dist/http/limits.d.ts +13 -0
- package/dist/http/limits.js +19 -0
- package/dist/http/meta.d.ts +11 -0
- package/dist/http/meta.js +15 -0
- package/dist/http/outcome.d.ts +11 -0
- package/dist/http/outcome.js +1 -0
- package/dist/http/rate-limit.d.ts +48 -0
- package/dist/http/rate-limit.js +171 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +85 -0
- package/dist/logger.d.ts +12 -0
- package/dist/logger.js +9 -0
- package/dist/node.d.ts +1 -0
- package/dist/node.js +41 -0
- package/dist/routes/health.d.ts +3 -0
- package/dist/routes/health.js +12 -0
- package/dist/routes/media.d.ts +3 -0
- package/dist/routes/media.js +116 -0
- package/dist/routes/messages.d.ts +3 -0
- package/dist/routes/messages.js +41 -0
- package/dist/routes/onboarding.d.ts +3 -0
- package/dist/routes/onboarding.js +123 -0
- package/dist/routes/proxy.d.ts +20 -0
- package/dist/routes/proxy.js +116 -0
- package/dist/routes/templates.d.ts +3 -0
- package/dist/routes/templates.js +37 -0
- package/dist/routes/webhook.d.ts +3 -0
- package/dist/routes/webhook.js +192 -0
- package/dist/schemas/common.d.ts +23 -0
- package/dist/schemas/common.js +84 -0
- package/dist/schemas/messages.d.ts +1282 -0
- package/dist/schemas/messages.js +350 -0
- package/dist/schemas/onboarding.d.ts +61 -0
- package/dist/schemas/onboarding.js +42 -0
- package/dist/services/crypto.d.ts +24 -0
- package/dist/services/crypto.js +70 -0
- package/dist/services/delivery.d.ts +40 -0
- package/dist/services/delivery.js +83 -0
- package/dist/services/errors.d.ts +75 -0
- package/dist/services/errors.js +150 -0
- package/dist/services/line-connect.d.ts +35 -0
- package/dist/services/line-connect.js +42 -0
- package/dist/services/meta-client.d.ts +118 -0
- package/dist/services/meta-client.js +311 -0
- package/dist/services/normalizer.d.ts +18 -0
- package/dist/services/normalizer.js +427 -0
- package/dist/services/replay.d.ts +42 -0
- package/dist/services/replay.js +98 -0
- package/dist/services/retry.d.ts +17 -0
- package/dist/services/retry.js +12 -0
- package/dist/services/store-adapters.d.ts +47 -0
- package/dist/services/store-adapters.js +127 -0
- package/dist/types/gateway.d.ts +343 -0
- package/dist/types/gateway.js +27 -0
- package/dist/types/meta.d.ts +247 -0
- package/dist/types/meta.js +4 -0
- package/dist/utils/env.d.ts +119 -0
- package/dist/utils/env.js +114 -0
- package/dist/utils/guards.d.ts +2 -0
- package/dist/utils/guards.js +4 -0
- package/dist/utils/redact.d.ts +2 -0
- package/dist/utils/redact.js +18 -0
- package/package.json +69 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 El Hub Ventures Studio
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,333 @@
|
|
|
1
|
-
#
|
|
1
|
+
# 🌊 watahub
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://opensource.org/licenses/MIT)
|
|
4
|
+
[](https://www.typescriptlang.org/)
|
|
5
|
+
[](https://hono.dev/)
|
|
6
|
+
[](https://vitest.dev/)
|
|
7
|
+
|
|
8
|
+
> **What a hub for WhatsApp!**
|
|
9
|
+
> An open-source, high-performance WhatsApp Cloud API Gateway and Bridge built with [Hono](https://hono.dev). Connect your applications directly to Meta's Cloud API with **zero middleman markups**, normalized webhook events, cryptographic HMAC verification, streaming media uploads, and an importable library architecture.
|
|
10
|
+
|
|
11
|
+
Built with ❤️ by [Hub Ventures](https://github.com/Hub-Ventures).
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 🎯 What is Watahub and Who is it For?
|
|
16
|
+
|
|
17
|
+
**Watahub** is designed for engineering teams and developers who already use or plan to use the official Meta WhatsApp Cloud API and want:
|
|
18
|
+
1. **Total Infrastructure Control:** Self-host your own gateway on Cloudflare Workers, Node.js, or Bun.
|
|
19
|
+
2. **Zero Markups:** Pay Meta's official conversation rates directly without third-party SaaS surcharges.
|
|
20
|
+
3. **Clean Developer Experience:** Receive normalized, flat JSON events instead of navigating Meta's deeply nested webhook structures.
|
|
21
|
+
4. **Hardened Security by Default:** Cryptographic signature validation, replay protection, and rate limiting out of the box.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## 📚 Documentation Suite
|
|
26
|
+
|
|
27
|
+
- 📖 [**Getting Started Guide**](docs/GETTING_STARTED.md): Step-by-step walkthrough from prerequisites to your first message and webhook.
|
|
28
|
+
- 📡 [**API Reference**](docs/API_REFERENCE.md): Full endpoints specification, schemas, and interactive examples for all routes.
|
|
29
|
+
- 🔔 [**Webhooks & Event Catalog**](docs/WEBHOOKS_AND_EVENTS.md): HMAC verification in TypeScript, Python & Go, deduplication, and normalized event JSONs.
|
|
30
|
+
- 📐 [**Architecture & Design**](docs/ARCHITECTURE.md): Deep dive into ports & adapters, dependency inversion, request lifecycles, and error engine.
|
|
31
|
+
- 🏢 [**Multi-Tenancy & Extensibility**](docs/MULTI_TENANCY_AND_EXTENSIONS.md): Scaling with `PhoneDirectory`, KV adapters, and custom enterprise databases.
|
|
32
|
+
- 🚢 [**Production Deployment**](docs/DEPLOYMENT.md): Production setups for Cloudflare Workers, Node.js (Docker/PM2), and Nginx reverse proxies.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## ⚡ Why Watahub?
|
|
37
|
+
|
|
38
|
+
* 🚀 **Zero Middleman Markup:** Pay only Meta's official conversation fees directly to Meta.
|
|
39
|
+
* ⚡ **Ultra-lightweight & Fast:** Built on Hono with Web Standards (`crypto.subtle`, `fetch`, `ReadableStream`).
|
|
40
|
+
* 🔄 **Event Normalization:** Transforms Meta's deeply nested webhook envelopes into clean, standardized event payloads (text, media, interactive buttons/lists, reactions, delivery status receipts).
|
|
41
|
+
* 🔐 **Cryptographic Verification:** Automatic HMAC-SHA256 verification of incoming Meta webhooks (`X-Hub-Signature-256`) and outgoing subscriber payload signing (`X-Watahub-Signature`).
|
|
42
|
+
* 📤 **Streaming Media Support:** Native `/v1/media` endpoints and streaming proxy passthrough that handle uploads up to 100 MB without isolate memory buffering.
|
|
43
|
+
* 🏢 **Pluggable Multi-Tenant Routing:** Modular `PhoneDirectory` port to dynamically resolve Meta access tokens and subscriber webhook targets per phone number.
|
|
44
|
+
* 🛡️ **Production-Hardened Error Engine:** Automated classification of Meta Graph API errors (Rate Limits `130429`, Auth Expiry `190`, Billing/Payment Failures `131042`, Transient Network Failures `131016`).
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## 📐 Architecture
|
|
49
|
+
|
|
50
|
+
```mermaid
|
|
51
|
+
flowchart LR
|
|
52
|
+
User["WhatsApp User"] <--> Meta["Meta Cloud API (v24.0)"]
|
|
53
|
+
Meta <--> Watahub["🌊 Watahub Gateway (Hono)"]
|
|
54
|
+
Watahub <--> Backend["Your Backend / Webhook Subscriber"]
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## 📋 Prerequisites
|
|
60
|
+
|
|
61
|
+
Before setting up Watahub, ensure you have:
|
|
62
|
+
|
|
63
|
+
1. **Meta for Developers Account:** An App with the **WhatsApp** product configured ([Meta Developer Portal](https://developers.facebook.com/)).
|
|
64
|
+
2. **WhatsApp Business Account (WABA):** A configured WABA with at least one verified phone number ID.
|
|
65
|
+
3. **Node.js Environment:** Node.js 20 or higher and `pnpm` (required for local development and build steps).
|
|
66
|
+
4. *(Optional for Cloudflare Workers)*: A Cloudflare account and Wrangler CLI installed (`pnpm dlx wrangler`).
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## 📦 Quick Start: From Zero to First Message
|
|
71
|
+
|
|
72
|
+
### 1. Clone & Install
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
git clone https://github.com/Hub-Ventures/watahub.git
|
|
76
|
+
cd watahub
|
|
77
|
+
pnpm install
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### 2. Environment Configuration
|
|
81
|
+
|
|
82
|
+
Copy the example environment file:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
cp .env.example .env
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Generate strong keys (min 32 characters) for your gateway and subscriber:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
# Generate WATAHUB_API_KEY
|
|
92
|
+
openssl rand -hex 32
|
|
93
|
+
|
|
94
|
+
# Generate SUBSCRIBER_WEBHOOK_SECRET
|
|
95
|
+
openssl rand -hex 32
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Configure your `.env`:
|
|
99
|
+
|
|
100
|
+
| Variable | Description | Default |
|
|
101
|
+
| :--- | :--- | :--- |
|
|
102
|
+
| `PORT` | Local Node.js server port | `3000` |
|
|
103
|
+
| `META_APP_ID` | Meta Developer App ID. Optional at boot; required by connect and launch | - |
|
|
104
|
+
| `META_APP_SECRET` | **Required.** Meta App Secret, used to verify `X-Hub-Signature-256` and to exchange Embedded Signup codes. Without it `POST /webhook` answers 503 | - |
|
|
105
|
+
| `META_CONFIGURATION_ID` | Embedded Signup configuration id (`FB.login` `config_id`). Optional at boot; required by `GET /v1/onboarding/launch` | - |
|
|
106
|
+
| `META_SYSTEM_USER_TOKEN` | Fallback Meta token when the phone has no stored business token and the request has no `X-Meta-Access-Token` | - |
|
|
107
|
+
| `META_WEBHOOK_VERIFY_TOKEN` | **Required.** Token for Meta's webhook handshake (min 16 characters) | - |
|
|
108
|
+
| `META_DEFAULT_PHONE_NUMBER_ID` | Default WhatsApp Business Phone Number ID | - |
|
|
109
|
+
| `META_DEFAULT_WABA_ID` | Default WhatsApp Business Account ID | - |
|
|
110
|
+
| `META_API_VERSION` | Graph API Version | `v24.0` |
|
|
111
|
+
| `WATAHUB_API_KEY` | **Required.** Key for every `/v1/*` and `/v24.0/*` call (min 32 characters). Without it those routes answer 503 | - |
|
|
112
|
+
| `SUBSCRIBER_WEBHOOK_URL` | Forwarding destination URL (https; `http://localhost` allowed for development) | - |
|
|
113
|
+
| `SUBSCRIBER_WEBHOOK_SECRET` | Required with `SUBSCRIBER_WEBHOOK_URL`. Signs deliveries (min 32 characters) | - |
|
|
114
|
+
| `META_WEBHOOK_MAX_AGE_SECONDS` | Signed deliveries whose newest event is older than this are acknowledged but not forwarded | `604800` (7 days) |
|
|
115
|
+
| `RATE_LIMIT_PER_MINUTE` | Optional per-client request limit on `/v1/*` and `/v24.0/*` | off |
|
|
116
|
+
| `TRUSTED_CLIENT_IP_HEADER` | Header with the real client IP when Node runs behind a reverse proxy (e.g. `X-Real-IP`) | - |
|
|
117
|
+
| `CORS_ALLOWED_ORIGINS` | Comma-separated exact origins allowed from browsers. No wildcards | off |
|
|
118
|
+
|
|
119
|
+
> [!NOTE]
|
|
120
|
+
> On Node.js, the gateway validates this configuration at boot and refuses to start if a required value is missing, too short, or retains the `.env.example` placeholder. Cloudflare Workers enforces these same rules per request on protected routes.
|
|
121
|
+
|
|
122
|
+
### 3. Start the Gateway
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
pnpm run dev:node
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### 4. Verify Gateway Health
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
curl http://localhost:3000/health
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Expected response:
|
|
135
|
+
```json
|
|
136
|
+
{
|
|
137
|
+
"status": "ok",
|
|
138
|
+
"service": "watahub",
|
|
139
|
+
"version": "0.2.0",
|
|
140
|
+
"timestamp": 1729620000000
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### 5. Configure Meta Webhook Handshake
|
|
145
|
+
|
|
146
|
+
1. Expose your local port (e.g., via `cloudflared tunnel` or `ngrok http 3000`).
|
|
147
|
+
2. In your Meta Developer App Dashboard, go to **WhatsApp > Configuration > Edit Webhook**.
|
|
148
|
+
3. Set **Callback URL** to `https://<your-public-url>/webhook`.
|
|
149
|
+
4. Set **Verify Token** to the exact value of your `META_WEBHOOK_VERIFY_TOKEN`.
|
|
150
|
+
5. Click **Verify and Save**. Meta will send a `GET /webhook` challenge handshake that Watahub verifies in constant time.
|
|
151
|
+
6. Under **Webhook fields**, subscribe to `messages`.
|
|
152
|
+
|
|
153
|
+
### 6. Send Your First Message
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
curl -X POST http://localhost:3000/v1/messages \
|
|
157
|
+
-H "Authorization: Bearer <WATAHUB_API_KEY>" \
|
|
158
|
+
-H "Content-Type: application/json" \
|
|
159
|
+
-d '{
|
|
160
|
+
"to": "+15551234567",
|
|
161
|
+
"phoneNumberId": "100609346426000",
|
|
162
|
+
"kind": "text",
|
|
163
|
+
"text": {
|
|
164
|
+
"body": "Hello from Watahub!"
|
|
165
|
+
}
|
|
166
|
+
}'
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Expected response:
|
|
170
|
+
```json
|
|
171
|
+
{
|
|
172
|
+
"success": true,
|
|
173
|
+
"messageId": "wamid.HBgLNTczMDAxMjM0NTY3FQIAEhgg...",
|
|
174
|
+
"recipient": "15551234567",
|
|
175
|
+
"phoneNumberId": "100609346426000"
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### 7. Receive Your First Normalized Webhook Event
|
|
180
|
+
|
|
181
|
+
When a user replies on WhatsApp, Meta posts to Watahub's `/webhook`. Watahub verifies the HMAC signature, normalizes the payload, returns `200 OK` to Meta immediately, and forwards the signed payload to your `SUBSCRIBER_WEBHOOK_URL`.
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## 🔄 Normalized Webhook Events
|
|
186
|
+
|
|
187
|
+
Meta's raw webhook format nests data under `entry[].changes[].value`. Watahub flattens these events into a clean, typed schema.
|
|
188
|
+
|
|
189
|
+
### Sample Forwarded Payload (`POST $SUBSCRIBER_WEBHOOK_URL`)
|
|
190
|
+
|
|
191
|
+
```json
|
|
192
|
+
{
|
|
193
|
+
"timestamp": 1729620000000,
|
|
194
|
+
"events": [
|
|
195
|
+
{
|
|
196
|
+
"id": "wamid.HBgLNTczMDAxMjM0NTY3FQIAEhgg...",
|
|
197
|
+
"timestamp": 1729620000000,
|
|
198
|
+
"phoneNumberId": "100609346426000",
|
|
199
|
+
"displayPhoneNumber": "15550293848",
|
|
200
|
+
"type": "message",
|
|
201
|
+
"message": {
|
|
202
|
+
"from": "573001234567",
|
|
203
|
+
"senderName": "Jane Doe",
|
|
204
|
+
"kind": "text",
|
|
205
|
+
"text": "Hello Watahub!"
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
],
|
|
209
|
+
"raw": {
|
|
210
|
+
"object": "whatsapp_business_account",
|
|
211
|
+
"entry": [ ... ]
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### Supported Event Types
|
|
217
|
+
- **Inbound Messages (`type: "message"`):** `text`, `image`, `audio`, `video`, `document`, `location`, `contacts`, `reaction`, and `interactive` (button replies, list selections).
|
|
218
|
+
- **Echoes (`type: "echo"`):** Outbound messages sent directly from the WhatsApp Business mobile app, normalized with recipient info and `isEcho: true`.
|
|
219
|
+
- **Status Updates (`type: "status"`):** Delivery lifecycle tracking (`sent`, `delivered`, `read`, `failed` with classified error codes).
|
|
220
|
+
|
|
221
|
+
---
|
|
222
|
+
|
|
223
|
+
## 🏢 Open Core & Enterprise Edition (/ee)
|
|
224
|
+
|
|
225
|
+
Watahub is structured as an **open core** project:
|
|
226
|
+
|
|
227
|
+
### 1. Single-Operator Core (Open Source, MIT)
|
|
228
|
+
One operator: one Meta app, one subscriber webhook, one `WATAHUB_API_KEY`, and N phone lines. The core does not make the developer perform Meta's onboarding by hand.
|
|
229
|
+
- **No tenant model:** No per-tenant API keys, no per-tenant subscriber URL, no phone ownership checks, no `tenantId`.
|
|
230
|
+
- **Static app config:** `META_APP_ID`, `META_APP_SECRET`, `META_CONFIGURATION_ID`, `META_API_VERSION`. The business token is not a pasted secret.
|
|
231
|
+
- **Line connect:** `POST /v1/onboarding/connect` exchanges the Embedded Signup code, stores the business token keyed by phone number id, subscribes that WABA to this gateway's webhook, and registers the phone. The response does not include the token.
|
|
232
|
+
- **Launch config:** `GET /v1/onboarding/launch` returns `{ appId, configurationId, apiVersion }` so a frontend can call `FB.login`. The app secret is never returned.
|
|
233
|
+
- **Token resolution:** `X-Meta-Access-Token`, then the stored token for that phone number id, then `META_SYSTEM_USER_TOKEN`.
|
|
234
|
+
- **One subscriber:** `SUBSCRIBER_WEBHOOK_URL`.
|
|
235
|
+
|
|
236
|
+
### 2. Enterprise Edition (`/ee`, Private Repository)
|
|
237
|
+
Multi-tenant routing. Not in this repository.
|
|
238
|
+
- **Per-tenant API keys** and phone ownership checks.
|
|
239
|
+
- **Per-tenant subscriber URLs** and signing secrets (`tenantId` stays in `/ee`).
|
|
240
|
+
- **Credential injection:** the wrapper resolves a tenant token and sends `X-Meta-Access-Token`. That header still wins over the MIT line store.
|
|
241
|
+
|
|
242
|
+
### 3. Embedded Signup
|
|
243
|
+
`POST /v1/onboarding/connect` is the operator path. It does not return the business token.
|
|
244
|
+
`GET /v1/onboarding/launch` is what a frontend needs for `FB.login`. `config_id` is not a parameter of the code exchange.
|
|
245
|
+
These stay for existing callers:
|
|
246
|
+
- `POST /v1/onboarding/exchange`: exchanges the code and **returns** the token. Prefer connect.
|
|
247
|
+
- `POST /v1/onboarding/subscribe`: subscribes a WABA using the header or `META_SYSTEM_USER_TOKEN`. Connect already subscribes with the new business token.
|
|
248
|
+
- `POST /v1/onboarding/override-callback`: explicit phone-level callback override. Connect does not set one; subscribing attaches Meta to the callback already configured on the app.
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
## 🚀 Running & Deploying Watahub
|
|
253
|
+
|
|
254
|
+
### Development (Node.js)
|
|
255
|
+
```bash
|
|
256
|
+
pnpm run dev:node
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
### Development (Cloudflare Workers via Wrangler)
|
|
260
|
+
```bash
|
|
261
|
+
pnpm run dev:worker
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
### Typecheck, Lint & Test Suite
|
|
265
|
+
```bash
|
|
266
|
+
pnpm check # typecheck + eslint + prettier check + vitest with coverage
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
### Production Deploy (Cloudflare Workers)
|
|
270
|
+
|
|
271
|
+
Before deploying to Cloudflare Workers, configure required secrets using the Wrangler CLI:
|
|
272
|
+
|
|
273
|
+
```bash
|
|
274
|
+
npx wrangler secret put META_APP_SECRET
|
|
275
|
+
npx wrangler secret put META_WEBHOOK_VERIFY_TOKEN
|
|
276
|
+
npx wrangler secret put WATAHUB_API_KEY
|
|
277
|
+
npx wrangler secret put META_SYSTEM_USER_TOKEN
|
|
278
|
+
|
|
279
|
+
# Optional subscriber credentials
|
|
280
|
+
npx wrangler secret put SUBSCRIBER_WEBHOOK_SECRET
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
Then deploy the worker:
|
|
284
|
+
```bash
|
|
285
|
+
pnpm run deploy
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
---
|
|
289
|
+
|
|
290
|
+
## 📡 API Reference Summary
|
|
291
|
+
|
|
292
|
+
| Method | Endpoint | Description |
|
|
293
|
+
| :--- | :--- | :--- |
|
|
294
|
+
| `GET` | `/health` | Service status, version, and server timestamp |
|
|
295
|
+
| `GET` | `/webhook` | Meta verification handshake (`hub.challenge`) |
|
|
296
|
+
| `POST` | `/webhook` | Ingests Meta webhooks, verifies HMAC, normalizes & dispatches |
|
|
297
|
+
| `POST` | `/v1/messages` | Unified message dispatch (text, template, media, interactive) |
|
|
298
|
+
| `GET` | `/v1/media/:mediaId` | Resolves media ID to temporary CDN download URL |
|
|
299
|
+
| `POST` | `/v1/media` | Uploads media file (`multipart/form-data`) streaming to Meta |
|
|
300
|
+
| `POST` | `/v1/media/:phoneNumberId` | Uploads media scoped to a specific phone number |
|
|
301
|
+
| `GET` | `/v1/templates/:wabaId` | Lists a WABA's message templates from Meta Graph API, one page per call (`?after=`) |
|
|
302
|
+
| `POST` | `/v1/onboarding/connect` | Connects one phone line (exchange, store, subscribe, register). Does not return the token |
|
|
303
|
+
| `GET` | `/v1/onboarding/launch` | Public Embedded Signup parameters for `FB.login` (`appId`, `configurationId`, `apiVersion`) |
|
|
304
|
+
| `POST` | `/v1/onboarding/exchange` | Low-level code exchange. Returns the token. Prefer connect |
|
|
305
|
+
| `POST` | `/v1/onboarding/subscribe` | Subscribes application to WABA message webhooks |
|
|
306
|
+
| `POST` | `/v1/onboarding/override-callback` | Overrides webhook URL for a specific phone number |
|
|
307
|
+
| `ALL` | `/v24.0/*` | Direct Graph API passthrough proxy for allowlisted routes |
|
|
308
|
+
|
|
309
|
+
---
|
|
310
|
+
|
|
311
|
+
## 🔒 Security Model
|
|
312
|
+
|
|
313
|
+
* **Authentication:** `/v1/*` and `/v24.0/*` require `WATAHUB_API_KEY` (in `X-API-Key` or `Authorization: Bearer`), compared in constant time. Keys must be at least 32 characters. A client address with more than 10 failed attempts in a minute receives `429 Too Many Requests` with a `Retry-After` header.
|
|
314
|
+
* **Inbound Webhooks:** `POST /webhook` strictly verifies `X-Hub-Signature-256`. Deliveries whose events are older than `META_WEBHOOK_MAX_AGE_SECONDS` are acknowledged with `200 OK` but dropped to protect against replay attacks.
|
|
315
|
+
* **Outbound Deliveries:** If `SUBSCRIBER_WEBHOOK_URL` is set without `SUBSCRIBER_WEBHOOK_SECRET`, `POST /webhook` answers `503` so Meta retries until the signing secret is configured. Forwards include `X-Watahub-Signature: sha256=...` and `X-Watahub-Delivery-Id`.
|
|
316
|
+
* **Streaming & Isolate Memory:** Media uploads stream directly to Meta via chunked Transfer-Encoding, preventing memory spikes and Workers isolate evictions on 100 MB payloads.
|
|
317
|
+
* **Pluggable State Stores:** Replay cache and rate limiting use pluggable ports (`ReplayStore`, `RateLimitStore`), defaulting to in-memory on Node and easily swapped with `DistributedReplayStore` and `DistributedRateLimitStore` on Workers.
|
|
318
|
+
* **Callback Overrides:** Destination URLs must be `https://` on a public host; loopback, private, link-local, and internal addresses are rejected.
|
|
319
|
+
* **HTTP Hardening:** Security headers (`Content-Security-Policy: default-src 'none'`, `X-Frame-Options: DENY`, HSTS, `X-Content-Type-Options: nosniff`) are set on every response. Responses on authenticated surfaces carry `Cache-Control: no-store`.
|
|
320
|
+
|
|
321
|
+
---
|
|
322
|
+
|
|
323
|
+
## 🚦 Project Status
|
|
324
|
+
|
|
325
|
+
Watahub is currently in active development (**v0.2.0**).
|
|
326
|
+
|
|
327
|
+
The core WhatsApp bridge functionality (message dispatch, streaming media uploads, HMAC verification, replay protection, rate limiting, and library exports) is production-ready and fully tested with 100% passing automated tests across Node.js and Cloudflare Workers.
|
|
328
|
+
|
|
329
|
+
---
|
|
330
|
+
|
|
331
|
+
## 📄 License
|
|
332
|
+
|
|
333
|
+
This project is licensed under the [MIT License](LICENSE).
|
package/dist/clock.d.ts
ADDED
package/dist/clock.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const systemClock = () => Date.now();
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Context } from 'hono';
|
|
2
|
+
export declare const GATEWAY_NAME = "watahub";
|
|
3
|
+
export declare const GATEWAY_VERSION = "0.2.0";
|
|
4
|
+
export declare const GATEWAY_USER_AGENT = "Watahub-Gateway/0.2.0";
|
|
5
|
+
export declare const META_GRAPH_BASE_URL = "https://graph.facebook.com";
|
|
6
|
+
export declare const DEFAULT_META_API_VERSION = "v24.0";
|
|
7
|
+
/**
|
|
8
|
+
* Typed view of the gateway configuration for the current request.
|
|
9
|
+
* Values come from Worker bindings first, then process.env (see getEnvVar).
|
|
10
|
+
* Boot-time rules live in utils/env.ts (validateEnv).
|
|
11
|
+
*/
|
|
12
|
+
export interface GatewayConfig {
|
|
13
|
+
apiKey?: string;
|
|
14
|
+
metaAppId?: string;
|
|
15
|
+
/** Embedded Signup configuration id. Public; never the app secret. */
|
|
16
|
+
metaConfigurationId?: string;
|
|
17
|
+
metaAppSecret?: string;
|
|
18
|
+
metaSystemUserToken?: string;
|
|
19
|
+
metaWebhookVerifyToken?: string;
|
|
20
|
+
metaDefaultPhoneNumberId?: string;
|
|
21
|
+
metaDefaultWabaId?: string;
|
|
22
|
+
metaApiVersion: string;
|
|
23
|
+
subscriberWebhookUrl?: string;
|
|
24
|
+
subscriberWebhookSecret?: string;
|
|
25
|
+
webhookDeliveryMode?: 'sync' | 'async';
|
|
26
|
+
metaWebhookMaxAgeSeconds?: string;
|
|
27
|
+
corsAllowedOrigins?: string;
|
|
28
|
+
rateLimitPerMinute?: string;
|
|
29
|
+
trustedClientIpHeader?: string;
|
|
30
|
+
}
|
|
31
|
+
export declare function getConfig(c: Context): GatewayConfig;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { getEnvVar } from './utils/env.js';
|
|
2
|
+
export const GATEWAY_NAME = 'watahub';
|
|
3
|
+
export const GATEWAY_VERSION = '0.2.0';
|
|
4
|
+
export const GATEWAY_USER_AGENT = `Watahub-Gateway/${GATEWAY_VERSION}`;
|
|
5
|
+
export const META_GRAPH_BASE_URL = 'https://graph.facebook.com';
|
|
6
|
+
export const DEFAULT_META_API_VERSION = 'v24.0';
|
|
7
|
+
export function getConfig(c) {
|
|
8
|
+
return {
|
|
9
|
+
apiKey: getEnvVar(c, 'WATAHUB_API_KEY'),
|
|
10
|
+
metaAppId: getEnvVar(c, 'META_APP_ID'),
|
|
11
|
+
metaConfigurationId: getEnvVar(c, 'META_CONFIGURATION_ID'),
|
|
12
|
+
metaAppSecret: getEnvVar(c, 'META_APP_SECRET'),
|
|
13
|
+
metaSystemUserToken: getEnvVar(c, 'META_SYSTEM_USER_TOKEN'),
|
|
14
|
+
metaWebhookVerifyToken: getEnvVar(c, 'META_WEBHOOK_VERIFY_TOKEN'),
|
|
15
|
+
metaDefaultPhoneNumberId: getEnvVar(c, 'META_DEFAULT_PHONE_NUMBER_ID'),
|
|
16
|
+
metaDefaultWabaId: getEnvVar(c, 'META_DEFAULT_WABA_ID'),
|
|
17
|
+
metaApiVersion: getEnvVar(c, 'META_API_VERSION') || DEFAULT_META_API_VERSION,
|
|
18
|
+
subscriberWebhookUrl: getEnvVar(c, 'SUBSCRIBER_WEBHOOK_URL'),
|
|
19
|
+
subscriberWebhookSecret: getEnvVar(c, 'SUBSCRIBER_WEBHOOK_SECRET'),
|
|
20
|
+
webhookDeliveryMode: getEnvVar(c, 'WEBHOOK_DELIVERY_MODE') || 'sync',
|
|
21
|
+
metaWebhookMaxAgeSeconds: getEnvVar(c, 'META_WEBHOOK_MAX_AGE_SECONDS'),
|
|
22
|
+
corsAllowedOrigins: getEnvVar(c, 'CORS_ALLOWED_ORIGINS'),
|
|
23
|
+
rateLimitPerMinute: getEnvVar(c, 'RATE_LIMIT_PER_MINUTE'),
|
|
24
|
+
trustedClientIpHeader: getEnvVar(c, 'TRUSTED_CLIENT_IP_HEADER'),
|
|
25
|
+
};
|
|
26
|
+
}
|
package/dist/deps.d.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { type Clock } from './clock.js';
|
|
2
|
+
import { type Logger } from './logger.js';
|
|
3
|
+
import { type MetaClientConfig, type MetaGraphClient } from './services/meta-client.js';
|
|
4
|
+
/**
|
|
5
|
+
* Business token for one phone line of this operator.
|
|
6
|
+
* Stored server-side. Never copied into an API response or a log line.
|
|
7
|
+
*/
|
|
8
|
+
export interface StoredLine {
|
|
9
|
+
phoneNumberId: string;
|
|
10
|
+
wabaId: string;
|
|
11
|
+
accessToken: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Phone-line credentials for one operator (one Meta app, N numbers).
|
|
15
|
+
* Sync for one process, async for KV. Not a tenant directory.
|
|
16
|
+
*/
|
|
17
|
+
export interface LineStore {
|
|
18
|
+
get(phoneNumberId: string): Promise<StoredLine | undefined> | StoredLine | undefined;
|
|
19
|
+
put(line: StoredLine): Promise<void> | void;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* In-process line store owned by `createApp`. Isolates do not share it;
|
|
23
|
+
* `DistributedLineStore` is the KV adapter when tokens must outlive the process.
|
|
24
|
+
*/
|
|
25
|
+
export declare class MemoryLineStore implements LineStore {
|
|
26
|
+
private readonly lines;
|
|
27
|
+
get(phoneNumberId: string): StoredLine | undefined;
|
|
28
|
+
put(line: StoredLine): void;
|
|
29
|
+
}
|
|
30
|
+
export interface ReplayStore {
|
|
31
|
+
has(id: string, nowMs?: number): boolean | Promise<boolean>;
|
|
32
|
+
/**
|
|
33
|
+
* Records a delivered ID. `ttlSeconds` is how long it must be kept so a replay
|
|
34
|
+
* inside the webhook age window is still caught; keep it at least that long.
|
|
35
|
+
*/
|
|
36
|
+
remember(id: string, nowMs?: number, ttlSeconds?: number): boolean | Promise<boolean>;
|
|
37
|
+
clear(): void | Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
export interface RateLimitStore {
|
|
40
|
+
blockedFor(key: string, nowMs?: number): number | Promise<number>;
|
|
41
|
+
hit(key: string, nowMs?: number): void | Promise<void>;
|
|
42
|
+
clear(): void | Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Collaborators the HTTP layer depends on. Pass overrides to `createApp` to
|
|
46
|
+
* swap implementations (tests, custom transports, structured logging) without
|
|
47
|
+
* touching routes.
|
|
48
|
+
*/
|
|
49
|
+
export interface GatewayDeps {
|
|
50
|
+
/** Builds the Meta Graph client for one request */
|
|
51
|
+
createMetaClient: (config: MetaClientConfig) => MetaGraphClient;
|
|
52
|
+
/** Outbound HTTP for the Graph proxy and subscriber delivery */
|
|
53
|
+
fetch: typeof fetch;
|
|
54
|
+
now: Clock;
|
|
55
|
+
logger: Logger;
|
|
56
|
+
/** Webhook replay protection store */
|
|
57
|
+
replayStore: ReplayStore;
|
|
58
|
+
/** Rate limiter for failed API keys */
|
|
59
|
+
apiKeyLimiter: RateLimitStore;
|
|
60
|
+
/** Rate limiter for failed webhook verify tokens */
|
|
61
|
+
verifyTokenLimiter: RateLimitStore;
|
|
62
|
+
/** Optional factory for per-client request rate limiters */
|
|
63
|
+
createRequestLimiter?: (limit: number) => RateLimitStore;
|
|
64
|
+
/** Business tokens produced when a phone line connects */
|
|
65
|
+
lineStore: LineStore;
|
|
66
|
+
}
|
|
67
|
+
export declare function createDefaultDeps(overrides?: Partial<GatewayDeps>): GatewayDeps;
|
package/dist/deps.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { systemClock } from './clock.js';
|
|
2
|
+
import { apiKeyFailures, verifyTokenFailures } from './http/rate-limit.js';
|
|
3
|
+
import { consoleLogger } from './logger.js';
|
|
4
|
+
import { MetaClient } from './services/meta-client.js';
|
|
5
|
+
import { webhookDeliveryIds } from './services/replay.js';
|
|
6
|
+
/**
|
|
7
|
+
* In-process line store owned by `createApp`. Isolates do not share it;
|
|
8
|
+
* `DistributedLineStore` is the KV adapter when tokens must outlive the process.
|
|
9
|
+
*/
|
|
10
|
+
export class MemoryLineStore {
|
|
11
|
+
lines = new Map();
|
|
12
|
+
get(phoneNumberId) {
|
|
13
|
+
return this.lines.get(phoneNumberId);
|
|
14
|
+
}
|
|
15
|
+
put(line) {
|
|
16
|
+
this.lines.set(line.phoneNumberId, {
|
|
17
|
+
phoneNumberId: line.phoneNumberId,
|
|
18
|
+
wabaId: line.wabaId,
|
|
19
|
+
accessToken: line.accessToken,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function createDefaultDeps(overrides = {}) {
|
|
24
|
+
// The global fetch is resolved on every call so runtime polyfills and test spies apply
|
|
25
|
+
const fetchFn = overrides.fetch ?? ((input, init) => fetch(input, init));
|
|
26
|
+
return {
|
|
27
|
+
createMetaClient: (config) => new MetaClient({ fetch: fetchFn, ...config }),
|
|
28
|
+
fetch: fetchFn,
|
|
29
|
+
now: systemClock,
|
|
30
|
+
logger: consoleLogger,
|
|
31
|
+
replayStore: overrides.replayStore ?? webhookDeliveryIds,
|
|
32
|
+
apiKeyLimiter: overrides.apiKeyLimiter ?? apiKeyFailures,
|
|
33
|
+
verifyTokenLimiter: overrides.verifyTokenLimiter ?? verifyTokenFailures,
|
|
34
|
+
...overrides,
|
|
35
|
+
// After the spread so a partial override cannot leave the port unset
|
|
36
|
+
lineStore: overrides.lineStore ?? new MemoryLineStore(),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Context, MiddlewareHandler } from 'hono';
|
|
2
|
+
import type { LineStore, RateLimitStore } from '../deps.js';
|
|
3
|
+
import type { Logger } from '../logger.js';
|
|
4
|
+
/**
|
|
5
|
+
* Gateway API key guard for every authenticated surface (/v1/*, /v24.0/*).
|
|
6
|
+
*
|
|
7
|
+
* Fails closed: if WATAHUB_API_KEY is not configured (or too weak) the gateway
|
|
8
|
+
* refuses to serve these routes instead of exposing the Meta system user token
|
|
9
|
+
* to anyone. Accepts either `Authorization: Bearer <key>` or `X-API-Key: <key>`.
|
|
10
|
+
*/
|
|
11
|
+
export declare function requireApiKey(logger: Logger, limiter?: RateLimitStore): MiddlewareHandler;
|
|
12
|
+
export interface MetaTokenSource {
|
|
13
|
+
/** When set, the line store is consulted after the header and before the env token. */
|
|
14
|
+
phoneNumberId?: string;
|
|
15
|
+
lineStore?: LineStore;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Resolves the Meta access token for the request:
|
|
19
|
+
* 1. `X-Meta-Access-Token` (operators and enterprise wrappers inject this).
|
|
20
|
+
* 2. The business token stored for `phoneNumberId`, when that line was connected.
|
|
21
|
+
* 3. `META_SYSTEM_USER_TOKEN`, the old single-token path.
|
|
22
|
+
*/
|
|
23
|
+
export declare function resolveMetaAccessToken(c: Context, source?: MetaTokenSource): Promise<string | undefined>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { getConfig } from '../config.js';
|
|
2
|
+
import { timingSafeEqual } from '../services/crypto.js';
|
|
3
|
+
import { isStrongApiKey } from '../utils/env.js';
|
|
4
|
+
import { apiKeyFailures, clientAddress, tooManyRequests } from './rate-limit.js';
|
|
5
|
+
/**
|
|
6
|
+
* Gateway API key guard for every authenticated surface (/v1/*, /v24.0/*).
|
|
7
|
+
*
|
|
8
|
+
* Fails closed: if WATAHUB_API_KEY is not configured (or too weak) the gateway
|
|
9
|
+
* refuses to serve these routes instead of exposing the Meta system user token
|
|
10
|
+
* to anyone. Accepts either `Authorization: Bearer <key>` or `X-API-Key: <key>`.
|
|
11
|
+
*/
|
|
12
|
+
export function requireApiKey(logger, limiter = apiKeyFailures) {
|
|
13
|
+
return async (c, next) => {
|
|
14
|
+
const { apiKey } = getConfig(c);
|
|
15
|
+
// Workers has no boot step, so the strength rules from validateEnv are enforced here too
|
|
16
|
+
if (!apiKey || !isStrongApiKey(apiKey)) {
|
|
17
|
+
logger.error('[watahub] WATAHUB_API_KEY is missing, too short or a placeholder; refusing authenticated route');
|
|
18
|
+
return c.json({ error: 'Gateway misconfigured: WATAHUB_API_KEY is missing or too weak' }, 503);
|
|
19
|
+
}
|
|
20
|
+
if (!(await hasValidApiKey(c, apiKey))) {
|
|
21
|
+
// Only failing callers are throttled, after the comparison: clients behind one
|
|
22
|
+
// proxy or NAT share an address, and a correct key must never be locked out by
|
|
23
|
+
// someone else's guesses. Guessing resistance comes from the 32+ character key.
|
|
24
|
+
const client = clientAddress(c);
|
|
25
|
+
const retryAfter = await limiter.blockedFor(client);
|
|
26
|
+
if (retryAfter > 0)
|
|
27
|
+
return tooManyRequests(c, retryAfter);
|
|
28
|
+
await limiter.hit(client);
|
|
29
|
+
return c.json({ error: 'Unauthorized: Invalid Watahub API key' }, 401);
|
|
30
|
+
}
|
|
31
|
+
await next();
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Drop-in Meta clients send `Authorization: Bearer <meta token>` next to X-API-Key,
|
|
36
|
+
* so either header may carry the gateway key. Every candidate is compared in constant time.
|
|
37
|
+
*/
|
|
38
|
+
async function hasValidApiKey(c, apiKey) {
|
|
39
|
+
const authHeader = c.req.header('authorization');
|
|
40
|
+
const bearer = authHeader?.startsWith('Bearer ') ? authHeader.slice('Bearer '.length) : undefined;
|
|
41
|
+
const candidates = [bearer, c.req.header('x-api-key')].filter((value) => !!value);
|
|
42
|
+
const matches = await Promise.all(candidates.map((candidate) => timingSafeEqual(candidate, apiKey)));
|
|
43
|
+
return matches.includes(true);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Resolves the Meta access token for the request:
|
|
47
|
+
* 1. `X-Meta-Access-Token` (operators and enterprise wrappers inject this).
|
|
48
|
+
* 2. The business token stored for `phoneNumberId`, when that line was connected.
|
|
49
|
+
* 3. `META_SYSTEM_USER_TOKEN`, the old single-token path.
|
|
50
|
+
*/
|
|
51
|
+
export async function resolveMetaAccessToken(c, source = {}) {
|
|
52
|
+
const headerToken = c.req.header('x-meta-access-token');
|
|
53
|
+
if (headerToken)
|
|
54
|
+
return headerToken;
|
|
55
|
+
if (source.phoneNumberId && source.lineStore) {
|
|
56
|
+
const line = await source.lineStore.get(source.phoneNumberId);
|
|
57
|
+
if (line?.accessToken)
|
|
58
|
+
return line.accessToken;
|
|
59
|
+
}
|
|
60
|
+
return getConfig(c).metaSystemUserToken;
|
|
61
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Context } from 'hono';
|
|
2
|
+
/**
|
|
3
|
+
* Keeps `task` alive after the response is sent. On Workers it is handed to
|
|
4
|
+
* waitUntil; on Node there is no ExecutionContext (Hono's getter throws) and
|
|
5
|
+
* the already-running promise completes on its own while being tracked in-flight
|
|
6
|
+
* for graceful shutdown.
|
|
7
|
+
*/
|
|
8
|
+
export declare function runInBackground(c: Context, task: Promise<unknown>): void;
|
|
9
|
+
/**
|
|
10
|
+
* Awaits all pending background tasks up to `timeoutMs` during graceful shutdown.
|
|
11
|
+
*/
|
|
12
|
+
export declare function drainBackgroundTasks(timeoutMs?: number): Promise<void>;
|
|
13
|
+
export declare function getInFlightTaskCount(): number;
|
|
14
|
+
export declare function clearInFlightTasksForTests(): void;
|