@rebasepro/cli 0.6.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +0 -1
- package/dist/commands/api-keys.d.ts +1 -0
- package/dist/commands/generate_sdk.d.ts +1 -1
- package/dist/index.es.js +464 -58
- package/dist/index.es.js.map +1 -1
- package/dist/utils/project.d.ts +15 -0
- package/package.json +8 -9
- package/templates/template/backend/Dockerfile +4 -3
- package/templates/template/backend/functions/hello.ts +32 -32
- package/templates/template/backend/package.json +1 -1
- package/templates/template/backend/src/index.ts +1 -2
- package/templates/template/config/collections/posts.ts +4 -4
- package/templates/template/config/collections/presets/ecommerce/categories.ts +3 -1
- package/templates/template/config/collections/presets/ecommerce/orders.ts +3 -1
- package/templates/template/config/collections/presets/ecommerce/products.ts +3 -1
- package/templates/template/config/collections/tags.ts +1 -3
- package/templates/template/config/collections/users.ts +4 -5
- package/templates/template/frontend/Dockerfile +5 -3
- package/templates/template/package.json +3 -2
- package/templates/template/pnpm-workspace.yaml +2 -0
- package/templates/template/scripts/example.ts +5 -2
- package/skills/rebase-admin/SKILL.md +0 -710
- package/skills/rebase-api/SKILL.md +0 -662
- package/skills/rebase-api/references/.gitkeep +0 -3
- package/skills/rebase-auth/SKILL.md +0 -1143
- package/skills/rebase-auth/references/.gitkeep +0 -3
- package/skills/rebase-backend-postgres/SKILL.md +0 -633
- package/skills/rebase-backend-postgres/references/.gitkeep +0 -3
- package/skills/rebase-basics/SKILL.md +0 -749
- package/skills/rebase-basics/references/.gitkeep +0 -3
- package/skills/rebase-collections/SKILL.md +0 -1328
- package/skills/rebase-collections/references/.gitkeep +0 -3
- package/skills/rebase-cron-jobs/SKILL.md +0 -699
- package/skills/rebase-cron-jobs/references/.gitkeep +0 -1
- package/skills/rebase-custom-functions/SKILL.md +0 -233
- package/skills/rebase-deployment/SKILL.md +0 -885
- package/skills/rebase-deployment/references/.gitkeep +0 -3
- package/skills/rebase-design-language/SKILL.md +0 -692
- package/skills/rebase-email/SKILL.md +0 -701
- package/skills/rebase-email/references/.gitkeep +0 -1
- package/skills/rebase-entity-history/SKILL.md +0 -485
- package/skills/rebase-entity-history/references/.gitkeep +0 -1
- package/skills/rebase-local-env-setup/SKILL.md +0 -189
- package/skills/rebase-local-env-setup/references/.gitkeep +0 -3
- package/skills/rebase-realtime/SKILL.md +0 -755
- package/skills/rebase-realtime/references/.gitkeep +0 -3
- package/skills/rebase-sdk/SKILL.md +0 -594
- package/skills/rebase-sdk/references/.gitkeep +0 -0
- package/skills/rebase-storage/SKILL.md +0 -765
- package/skills/rebase-storage/references/.gitkeep +0 -3
- package/skills/rebase-studio/SKILL.md +0 -746
- package/skills/rebase-studio/references/.gitkeep +0 -3
- package/skills/rebase-ui-components/SKILL.md +0 -1488
- package/skills/rebase-ui-components/references/.gitkeep +0 -3
- package/skills/rebase-webhooks/SKILL.md +0 -623
- package/skills/rebase-webhooks/references/.gitkeep +0 -1
- package/templates/template/backend/drizzle.config.ts +0 -51
|
@@ -1,623 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: rebase-webhooks
|
|
3
|
-
description: Guide for sending outbound HTTP webhooks on entity changes in a Rebase backend. Use this skill when the user needs to notify external services on INSERT, UPDATE, or DELETE, verify HMAC signatures, understand retry/backoff behavior, or build a webhook receiver.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Rebase Webhooks
|
|
7
|
-
|
|
8
|
-
> **IMPORTANT FOR AGENTS**: The `WebhookDispatcher` is a **standalone service class** exported from `@rebasepro/server-core`. It is **not** auto-wired into the backend init pipeline — you must instantiate it yourself and call `onEntityChange()` from your application code (e.g. entity callbacks, custom functions, or cron jobs). Do NOT look for a `webhooks` key in `RebaseBackendConfig`.
|
|
9
|
-
|
|
10
|
-
> **IMPORTANT FOR AGENTS**: Webhooks are **outbound** HTTP POST requests sent by your Rebase backend to external URLs. They are NOT inbound endpoints. To receive webhooks FROM external services, use Rebase custom functions instead (see `rebase-custom-functions` skill).
|
|
11
|
-
|
|
12
|
-
## Overview
|
|
13
|
-
|
|
14
|
-
Rebase provides a `WebhookDispatcher` class for sending HTTP webhook notifications when entities change. It handles:
|
|
15
|
-
|
|
16
|
-
- **Table + event matching** — Only dispatches to webhooks whose `table` and `events` match
|
|
17
|
-
- **HMAC-SHA256 signing** — Optional payload signing for receiver verification
|
|
18
|
-
- **Automatic retries** — Up to 3 attempts with exponential backoff (1s → 5s → 15s)
|
|
19
|
-
- **Custom headers** — Attach authorization tokens or other headers to outbound requests
|
|
20
|
-
- **10-second timeout** — Requests are aborted if the receiver doesn't respond in time
|
|
21
|
-
- **Multiple webhooks** — Multiple webhooks can match the same entity change
|
|
22
|
-
|
|
23
|
-
## Setup
|
|
24
|
-
|
|
25
|
-
### Import and Instantiate
|
|
26
|
-
|
|
27
|
-
```typescript
|
|
28
|
-
import { WebhookDispatcher } from "@rebasepro/server-core/services/webhook-service";
|
|
29
|
-
import type { WebhookConfig } from "@rebasepro/server-core/services/webhook-service";
|
|
30
|
-
|
|
31
|
-
const dispatcher = new WebhookDispatcher();
|
|
32
|
-
```
|
|
33
|
-
|
|
34
|
-
### Register Webhooks
|
|
35
|
-
|
|
36
|
-
Call `setWebhooks()` with an array of `WebhookConfig` objects. Only **enabled** webhooks are kept — disabled ones are filtered out automatically.
|
|
37
|
-
|
|
38
|
-
```typescript
|
|
39
|
-
dispatcher.setWebhooks([
|
|
40
|
-
{
|
|
41
|
-
id: "wh_orders_new",
|
|
42
|
-
url: "https://my-service.com/webhooks/orders",
|
|
43
|
-
secret: process.env.WEBHOOK_SECRET,
|
|
44
|
-
events: ["INSERT"],
|
|
45
|
-
table: "orders",
|
|
46
|
-
enabled: true,
|
|
47
|
-
},
|
|
48
|
-
{
|
|
49
|
-
id: "wh_users_all",
|
|
50
|
-
url: "https://analytics.example.com/hooks/users",
|
|
51
|
-
secret: "my-hmac-secret",
|
|
52
|
-
headers: {
|
|
53
|
-
"Authorization": "Bearer sk_live_abc123",
|
|
54
|
-
"X-Source": "rebase",
|
|
55
|
-
},
|
|
56
|
-
events: ["INSERT", "UPDATE", "DELETE"],
|
|
57
|
-
table: "users",
|
|
58
|
-
enabled: true,
|
|
59
|
-
},
|
|
60
|
-
{
|
|
61
|
-
id: "wh_disabled_example",
|
|
62
|
-
url: "https://example.com/hook",
|
|
63
|
-
events: ["INSERT"],
|
|
64
|
-
table: "posts",
|
|
65
|
-
enabled: false, // ← filtered out, never dispatched
|
|
66
|
-
},
|
|
67
|
-
]);
|
|
68
|
-
```
|
|
69
|
-
|
|
70
|
-
> **WARNING FOR AGENTS**: `setWebhooks()` **replaces** the entire webhook list each time it's called. It does NOT append. Disabled webhooks (`enabled: false`) are silently dropped.
|
|
71
|
-
|
|
72
|
-
### Dispatch on Entity Changes
|
|
73
|
-
|
|
74
|
-
Call `onEntityChange()` whenever an entity is created, updated, or deleted. The dispatcher checks all registered webhooks and fires matching ones.
|
|
75
|
-
|
|
76
|
-
```typescript
|
|
77
|
-
const results = await dispatcher.onEntityChange(
|
|
78
|
-
"orders", // table name
|
|
79
|
-
"INSERT", // event type
|
|
80
|
-
"order_abc123", // entity ID
|
|
81
|
-
{ id: "order_abc123", total: 99 } // the entity record
|
|
82
|
-
);
|
|
83
|
-
```
|
|
84
|
-
|
|
85
|
-
For **UPDATE** events, pass the previous entity as the 5th argument:
|
|
86
|
-
|
|
87
|
-
```typescript
|
|
88
|
-
const results = await dispatcher.onEntityChange(
|
|
89
|
-
"users",
|
|
90
|
-
"UPDATE",
|
|
91
|
-
"user_42",
|
|
92
|
-
{ id: "user_42", name: "Updated Name" }, // current record
|
|
93
|
-
{ id: "user_42", name: "Original Name" } // previous record
|
|
94
|
-
);
|
|
95
|
-
```
|
|
96
|
-
|
|
97
|
-
For **DELETE** events, the entity may be `null`:
|
|
98
|
-
|
|
99
|
-
```typescript
|
|
100
|
-
const results = await dispatcher.onEntityChange(
|
|
101
|
-
"orders",
|
|
102
|
-
"DELETE",
|
|
103
|
-
"order_abc123",
|
|
104
|
-
null // entity was deleted
|
|
105
|
-
);
|
|
106
|
-
```
|
|
107
|
-
|
|
108
|
-
## WebhookConfig Interface
|
|
109
|
-
|
|
110
|
-
```typescript
|
|
111
|
-
interface WebhookConfig {
|
|
112
|
-
id: string;
|
|
113
|
-
url: string;
|
|
114
|
-
secret?: string;
|
|
115
|
-
headers?: Record<string, string>;
|
|
116
|
-
events: string[];
|
|
117
|
-
table: string;
|
|
118
|
-
enabled: boolean;
|
|
119
|
-
}
|
|
120
|
-
```
|
|
121
|
-
|
|
122
|
-
| Property | Type | Required | Default | Description |
|
|
123
|
-
|----------|------|----------|---------|-------------|
|
|
124
|
-
| `id` | `string` | ✅ | — | Unique identifier for this webhook. Sent in the `X-Webhook-Id` header. |
|
|
125
|
-
| `url` | `string` | ✅ | — | The endpoint URL that receives the POST request. |
|
|
126
|
-
| `secret` | `string` | ❌ | `undefined` | HMAC-SHA256 signing secret. When set, `X-Webhook-Signature` header is included. |
|
|
127
|
-
| `headers` | `Record<string, string>` | ❌ | `undefined` | Custom headers merged into every outbound request (e.g. `Authorization`). |
|
|
128
|
-
| `events` | `string[]` | ✅ | — | Event types to match. Values: `"INSERT"`, `"UPDATE"`, `"DELETE"`. |
|
|
129
|
-
| `table` | `string` | ✅ | — | The database table name to match against. |
|
|
130
|
-
| `enabled` | `boolean` | ✅ | — | Whether this webhook is active. Disabled webhooks are filtered out by `setWebhooks()`. |
|
|
131
|
-
|
|
132
|
-
## WebhookDeliveryResult Interface
|
|
133
|
-
|
|
134
|
-
Every call to `onEntityChange()` returns an array of `WebhookDeliveryResult` — one per matching webhook.
|
|
135
|
-
|
|
136
|
-
```typescript
|
|
137
|
-
interface WebhookDeliveryResult {
|
|
138
|
-
webhookId: string;
|
|
139
|
-
event: string;
|
|
140
|
-
payload: Record<string, unknown>;
|
|
141
|
-
statusCode: number;
|
|
142
|
-
responseBody: string;
|
|
143
|
-
success: boolean;
|
|
144
|
-
attemptNumber: number;
|
|
145
|
-
}
|
|
146
|
-
```
|
|
147
|
-
|
|
148
|
-
| Property | Type | Description |
|
|
149
|
-
|----------|------|-------------|
|
|
150
|
-
| `webhookId` | `string` | The `id` of the webhook that was dispatched. |
|
|
151
|
-
| `event` | `string` | The event type (`INSERT`, `UPDATE`, `DELETE`). |
|
|
152
|
-
| `payload` | `Record<string, unknown>` | The full JSON payload that was sent. |
|
|
153
|
-
| `statusCode` | `number` | HTTP status code from the receiver. `0` if the request failed entirely (network error, timeout). |
|
|
154
|
-
| `responseBody` | `string` | Response body from the receiver, **truncated to 1000 characters**. Error message if the request failed. |
|
|
155
|
-
| `success` | `boolean` | `true` if the final attempt returned a 2xx status code. |
|
|
156
|
-
| `attemptNumber` | `number` | The attempt number of the final delivery (1–3). |
|
|
157
|
-
|
|
158
|
-
## Webhook Payload Format
|
|
159
|
-
|
|
160
|
-
Every webhook sends a `POST` request with a JSON body containing:
|
|
161
|
-
|
|
162
|
-
```json
|
|
163
|
-
{
|
|
164
|
-
"type": "INSERT",
|
|
165
|
-
"table": "orders",
|
|
166
|
-
"record": {
|
|
167
|
-
"id": "order_abc123",
|
|
168
|
-
"total": 99,
|
|
169
|
-
"status": "pending"
|
|
170
|
-
},
|
|
171
|
-
"old_record": null,
|
|
172
|
-
"schema": "public",
|
|
173
|
-
"timestamp": "2025-01-15T12:00:00.000Z"
|
|
174
|
-
}
|
|
175
|
-
```
|
|
176
|
-
|
|
177
|
-
| Field | Type | Description |
|
|
178
|
-
|-------|------|-------------|
|
|
179
|
-
| `type` | `string` | The event type: `"INSERT"`, `"UPDATE"`, or `"DELETE"`. |
|
|
180
|
-
| `table` | `string` | The database table name where the change occurred. |
|
|
181
|
-
| `record` | `object \| null` | The current state of the entity. `null` for DELETE events if no entity data available. |
|
|
182
|
-
| `old_record` | `object \| undefined` | The previous state of the entity. **Only present for `UPDATE` events.** `undefined` for INSERT and DELETE. |
|
|
183
|
-
| `schema` | `string` | Always `"public"`. |
|
|
184
|
-
| `timestamp` | `string` | ISO 8601 timestamp of when the webhook was dispatched. |
|
|
185
|
-
|
|
186
|
-
### Payload by Event Type
|
|
187
|
-
|
|
188
|
-
| Event | `record` | `old_record` |
|
|
189
|
-
|-------|----------|--------------|
|
|
190
|
-
| `INSERT` | The newly created entity | `undefined` |
|
|
191
|
-
| `UPDATE` | The updated entity (new state) | The entity before the update (old state) |
|
|
192
|
-
| `DELETE` | The deleted entity (or `null`) | `undefined` |
|
|
193
|
-
|
|
194
|
-
## HTTP Headers
|
|
195
|
-
|
|
196
|
-
Every webhook request includes these headers:
|
|
197
|
-
|
|
198
|
-
| Header | Value | Always Present |
|
|
199
|
-
|--------|-------|----------------|
|
|
200
|
-
| `Content-Type` | `application/json` | ✅ |
|
|
201
|
-
| `X-Webhook-Id` | The webhook's `id` (e.g. `wh_orders_new`) | ✅ |
|
|
202
|
-
| `X-Webhook-Event` | The event type (`INSERT`, `UPDATE`, `DELETE`) | ✅ |
|
|
203
|
-
| `X-Webhook-Delivery` | A unique UUID for this specific delivery attempt | ✅ |
|
|
204
|
-
| `X-Webhook-Attempt` | The attempt number (`"1"`, `"2"`, or `"3"`) | ✅ |
|
|
205
|
-
| `X-Webhook-Signature` | HMAC-SHA256 signature (e.g. `sha256=abc123...`) | Only when `secret` is set |
|
|
206
|
-
| *(custom headers)* | Values from `headers` config | Only when `headers` is set |
|
|
207
|
-
|
|
208
|
-
> **IMPORTANT FOR AGENTS**: Custom headers from the `headers` config are **merged after** the standard headers. This means custom headers can **override** built-in headers like `Content-Type` if the same key is used.
|
|
209
|
-
|
|
210
|
-
## HMAC Signature Verification
|
|
211
|
-
|
|
212
|
-
When a webhook has a `secret` configured, every request includes an `X-Webhook-Signature` header containing an HMAC-SHA256 signature of the raw JSON body.
|
|
213
|
-
|
|
214
|
-
### How Signing Works
|
|
215
|
-
|
|
216
|
-
1. The payload is serialized to JSON: `JSON.stringify(payload)`
|
|
217
|
-
2. An HMAC is computed: `createHmac("sha256", secret).update(body).digest("hex")`
|
|
218
|
-
3. The signature is sent as: `sha256=<hex_digest>`
|
|
219
|
-
|
|
220
|
-
### Verifying Signatures (Receiver Side)
|
|
221
|
-
|
|
222
|
-
> **IMPORTANT FOR AGENTS**: Always use **timing-safe comparison** (`timingSafeEqual`) when verifying HMAC signatures to prevent timing attacks. Never use `===` for signature comparison.
|
|
223
|
-
|
|
224
|
-
#### Node.js / Express Example
|
|
225
|
-
|
|
226
|
-
```typescript
|
|
227
|
-
import { createHmac, timingSafeEqual } from "crypto";
|
|
228
|
-
import express from "express";
|
|
229
|
-
|
|
230
|
-
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET!;
|
|
231
|
-
|
|
232
|
-
function verifyWebhookSignature(
|
|
233
|
-
body: string,
|
|
234
|
-
signatureHeader: string,
|
|
235
|
-
secret: string
|
|
236
|
-
): boolean {
|
|
237
|
-
const expectedSig = createHmac("sha256", secret)
|
|
238
|
-
.update(body)
|
|
239
|
-
.digest("hex");
|
|
240
|
-
const expected = `sha256=${expectedSig}`;
|
|
241
|
-
|
|
242
|
-
if (signatureHeader.length !== expected.length) return false;
|
|
243
|
-
|
|
244
|
-
return timingSafeEqual(
|
|
245
|
-
Buffer.from(signatureHeader),
|
|
246
|
-
Buffer.from(expected)
|
|
247
|
-
);
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
const app = express();
|
|
251
|
-
|
|
252
|
-
// IMPORTANT: Use raw body for signature verification
|
|
253
|
-
app.post("/webhooks/rebase", express.raw({ type: "application/json" }), (req, res) => {
|
|
254
|
-
const signature = req.headers["x-webhook-signature"] as string;
|
|
255
|
-
const rawBody = req.body.toString("utf-8");
|
|
256
|
-
|
|
257
|
-
if (!signature || !verifyWebhookSignature(rawBody, signature, WEBHOOK_SECRET)) {
|
|
258
|
-
return res.status(401).json({ error: "Invalid signature" });
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
const payload = JSON.parse(rawBody);
|
|
262
|
-
|
|
263
|
-
console.log(`Received ${payload.type} on ${payload.table}`, payload.record);
|
|
264
|
-
|
|
265
|
-
// Process the webhook...
|
|
266
|
-
res.status(200).json({ received: true });
|
|
267
|
-
});
|
|
268
|
-
```
|
|
269
|
-
|
|
270
|
-
#### Rebase Custom Function Receiver
|
|
271
|
-
|
|
272
|
-
```typescript
|
|
273
|
-
// backend/functions/webhook-receiver.ts
|
|
274
|
-
import type { RebaseFunctionDefinition } from "@rebasepro/types";
|
|
275
|
-
import { createHmac, timingSafeEqual } from "crypto";
|
|
276
|
-
|
|
277
|
-
const WEBHOOK_SECRET = process.env.EXTERNAL_WEBHOOK_SECRET!;
|
|
278
|
-
|
|
279
|
-
const fn: RebaseFunctionDefinition = {
|
|
280
|
-
method: "POST",
|
|
281
|
-
path: "/incoming-webhook",
|
|
282
|
-
public: true, // External services need to reach this
|
|
283
|
-
|
|
284
|
-
async handler(ctx) {
|
|
285
|
-
const signature = ctx.req.header("x-webhook-signature");
|
|
286
|
-
const rawBody = await ctx.req.text();
|
|
287
|
-
|
|
288
|
-
if (!signature) {
|
|
289
|
-
return ctx.json({ error: "Missing signature" }, 401);
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
const expectedSig = createHmac("sha256", WEBHOOK_SECRET)
|
|
293
|
-
.update(rawBody)
|
|
294
|
-
.digest("hex");
|
|
295
|
-
const expected = `sha256=${expectedSig}`;
|
|
296
|
-
|
|
297
|
-
if (
|
|
298
|
-
signature.length !== expected.length ||
|
|
299
|
-
!timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
|
|
300
|
-
) {
|
|
301
|
-
return ctx.json({ error: "Invalid signature" }, 401);
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
const payload = JSON.parse(rawBody);
|
|
305
|
-
// Process payload...
|
|
306
|
-
|
|
307
|
-
return ctx.json({ received: true });
|
|
308
|
-
},
|
|
309
|
-
};
|
|
310
|
-
|
|
311
|
-
export default fn;
|
|
312
|
-
```
|
|
313
|
-
|
|
314
|
-
## Retry Logic
|
|
315
|
-
|
|
316
|
-
The `WebhookDispatcher` automatically retries failed deliveries with **exponential backoff**.
|
|
317
|
-
|
|
318
|
-
### Retry Behavior
|
|
319
|
-
|
|
320
|
-
| Attempt | Delay Before | Total Elapsed |
|
|
321
|
-
|---------|-------------|---------------|
|
|
322
|
-
| 1 | — (immediate) | 0s |
|
|
323
|
-
| 2 | 1,000 ms (1s) | ~1s |
|
|
324
|
-
| 3 | 5,000 ms (5s) | ~6s |
|
|
325
|
-
|
|
326
|
-
- **Max retries:** 3 attempts total
|
|
327
|
-
- **Backoff delays:** `[1000, 5000, 15000]` ms (the 15s delay is defined but never used since the 3rd attempt is the last)
|
|
328
|
-
- A delivery is **successful** if the HTTP response status code is `>= 200 && < 300`
|
|
329
|
-
- A delivery **fails** if the status code is outside 2xx range OR if a network/timeout error occurs
|
|
330
|
-
- If any attempt succeeds, retries stop immediately and the successful result is returned
|
|
331
|
-
- If all 3 attempts fail, the result from the **last attempt** is returned
|
|
332
|
-
|
|
333
|
-
### What Triggers a Retry
|
|
334
|
-
|
|
335
|
-
| Scenario | Retries? |
|
|
336
|
-
|----------|----------|
|
|
337
|
-
| HTTP 2xx response | ❌ No — success, stop immediately |
|
|
338
|
-
| HTTP 4xx response (e.g. 400, 404) | ✅ Yes — retries up to max |
|
|
339
|
-
| HTTP 5xx response (e.g. 500, 502) | ✅ Yes — retries up to max |
|
|
340
|
-
| Network error (DNS failure, connection refused) | ✅ Yes — `statusCode: 0` |
|
|
341
|
-
| Request timeout (>10 seconds) | ✅ Yes — `statusCode: 0`, `AbortError` |
|
|
342
|
-
|
|
343
|
-
> **WARNING FOR AGENTS**: The dispatcher retries on **all** non-2xx status codes, including 4xx client errors. There is no distinction between retryable and non-retryable HTTP errors. If the receiver returns 400 Bad Request, the dispatcher will still retry twice more.
|
|
344
|
-
|
|
345
|
-
### Timeout
|
|
346
|
-
|
|
347
|
-
Each individual delivery attempt has a **10-second timeout** enforced via `AbortController`:
|
|
348
|
-
|
|
349
|
-
```typescript
|
|
350
|
-
const controller = new AbortController();
|
|
351
|
-
const timeout = setTimeout(() => controller.abort(), 10000); // 10s
|
|
352
|
-
```
|
|
353
|
-
|
|
354
|
-
If the receiver does not respond within 10 seconds, the request is aborted and treated as a failure (triggering a retry if attempts remain).
|
|
355
|
-
|
|
356
|
-
## Integration Patterns
|
|
357
|
-
|
|
358
|
-
### With Entity Callbacks
|
|
359
|
-
|
|
360
|
-
The most common pattern is to wire the dispatcher into Rebase entity callbacks so webhooks fire automatically on CRUD operations:
|
|
361
|
-
|
|
362
|
-
```typescript
|
|
363
|
-
// backend/collections/orders.ts
|
|
364
|
-
import type { EntityCollection, EntityCallbacks } from "@rebasepro/types";
|
|
365
|
-
import { WebhookDispatcher } from "@rebasepro/server-core/services/webhook-service";
|
|
366
|
-
|
|
367
|
-
const dispatcher = new WebhookDispatcher();
|
|
368
|
-
dispatcher.setWebhooks([
|
|
369
|
-
{
|
|
370
|
-
id: "wh_orders",
|
|
371
|
-
url: process.env.ORDERS_WEBHOOK_URL!,
|
|
372
|
-
secret: process.env.ORDERS_WEBHOOK_SECRET,
|
|
373
|
-
events: ["INSERT", "UPDATE", "DELETE"],
|
|
374
|
-
table: "orders",
|
|
375
|
-
enabled: true,
|
|
376
|
-
},
|
|
377
|
-
]);
|
|
378
|
-
|
|
379
|
-
const callbacks: EntityCallbacks = {
|
|
380
|
-
afterCreate: async ({ entity, collection }) => {
|
|
381
|
-
await dispatcher.onEntityChange(
|
|
382
|
-
collection.path,
|
|
383
|
-
"INSERT",
|
|
384
|
-
entity.id,
|
|
385
|
-
entity
|
|
386
|
-
);
|
|
387
|
-
},
|
|
388
|
-
afterUpdate: async ({ entity, previousEntity, collection }) => {
|
|
389
|
-
await dispatcher.onEntityChange(
|
|
390
|
-
collection.path,
|
|
391
|
-
"UPDATE",
|
|
392
|
-
entity.id,
|
|
393
|
-
entity,
|
|
394
|
-
previousEntity
|
|
395
|
-
);
|
|
396
|
-
},
|
|
397
|
-
afterDelete: async ({ entityId, collection }) => {
|
|
398
|
-
await dispatcher.onEntityChange(
|
|
399
|
-
collection.path,
|
|
400
|
-
"DELETE",
|
|
401
|
-
entityId,
|
|
402
|
-
null
|
|
403
|
-
);
|
|
404
|
-
},
|
|
405
|
-
};
|
|
406
|
-
|
|
407
|
-
const ordersCollection: EntityCollection = {
|
|
408
|
-
name: "Orders",
|
|
409
|
-
path: "orders",
|
|
410
|
-
callbacks,
|
|
411
|
-
// ... properties
|
|
412
|
-
};
|
|
413
|
-
|
|
414
|
-
export default ordersCollection;
|
|
415
|
-
```
|
|
416
|
-
|
|
417
|
-
### With Custom Functions (Manual Dispatch)
|
|
418
|
-
|
|
419
|
-
Trigger webhooks from a custom function endpoint:
|
|
420
|
-
|
|
421
|
-
```typescript
|
|
422
|
-
// backend/functions/process-payment.ts
|
|
423
|
-
import type { RebaseFunctionDefinition } from "@rebasepro/types";
|
|
424
|
-
import { WebhookDispatcher } from "@rebasepro/server-core/services/webhook-service";
|
|
425
|
-
|
|
426
|
-
const dispatcher = new WebhookDispatcher();
|
|
427
|
-
dispatcher.setWebhooks([
|
|
428
|
-
{
|
|
429
|
-
id: "wh_payments",
|
|
430
|
-
url: "https://accounting.example.com/hooks/payments",
|
|
431
|
-
secret: process.env.PAYMENT_WEBHOOK_SECRET,
|
|
432
|
-
events: ["INSERT"],
|
|
433
|
-
table: "payments",
|
|
434
|
-
enabled: true,
|
|
435
|
-
},
|
|
436
|
-
]);
|
|
437
|
-
|
|
438
|
-
const fn: RebaseFunctionDefinition = {
|
|
439
|
-
method: "POST",
|
|
440
|
-
path: "/process-payment",
|
|
441
|
-
|
|
442
|
-
async handler(ctx) {
|
|
443
|
-
const { orderId, amount } = await ctx.req.json();
|
|
444
|
-
|
|
445
|
-
// Create the payment record
|
|
446
|
-
const payment = await ctx.client.collection("payments").create({
|
|
447
|
-
orderId,
|
|
448
|
-
amount,
|
|
449
|
-
status: "completed",
|
|
450
|
-
});
|
|
451
|
-
|
|
452
|
-
// Manually dispatch the webhook
|
|
453
|
-
const results = await dispatcher.onEntityChange(
|
|
454
|
-
"payments",
|
|
455
|
-
"INSERT",
|
|
456
|
-
payment.id,
|
|
457
|
-
payment
|
|
458
|
-
);
|
|
459
|
-
|
|
460
|
-
const allSuccess = results.every(r => r.success);
|
|
461
|
-
|
|
462
|
-
return ctx.json({
|
|
463
|
-
payment,
|
|
464
|
-
webhooksDelivered: results.length,
|
|
465
|
-
webhooksSucceeded: allSuccess,
|
|
466
|
-
});
|
|
467
|
-
},
|
|
468
|
-
};
|
|
469
|
-
|
|
470
|
-
export default fn;
|
|
471
|
-
```
|
|
472
|
-
|
|
473
|
-
### Shared Dispatcher Instance
|
|
474
|
-
|
|
475
|
-
For applications with many collections, create a single shared dispatcher:
|
|
476
|
-
|
|
477
|
-
```typescript
|
|
478
|
-
// backend/lib/webhooks.ts
|
|
479
|
-
import { WebhookDispatcher } from "@rebasepro/server-core/services/webhook-service";
|
|
480
|
-
import type { WebhookConfig } from "@rebasepro/server-core/services/webhook-service";
|
|
481
|
-
|
|
482
|
-
const dispatcher = new WebhookDispatcher();
|
|
483
|
-
|
|
484
|
-
// Load webhook configs from environment or database
|
|
485
|
-
const webhooks: WebhookConfig[] = [
|
|
486
|
-
{
|
|
487
|
-
id: "wh_orders",
|
|
488
|
-
url: process.env.ORDERS_WEBHOOK_URL!,
|
|
489
|
-
secret: process.env.WEBHOOK_SECRET,
|
|
490
|
-
events: ["INSERT", "UPDATE", "DELETE"],
|
|
491
|
-
table: "orders",
|
|
492
|
-
enabled: !!process.env.ORDERS_WEBHOOK_URL,
|
|
493
|
-
},
|
|
494
|
-
{
|
|
495
|
-
id: "wh_users",
|
|
496
|
-
url: process.env.USERS_WEBHOOK_URL!,
|
|
497
|
-
secret: process.env.WEBHOOK_SECRET,
|
|
498
|
-
events: ["INSERT", "UPDATE"],
|
|
499
|
-
table: "users",
|
|
500
|
-
enabled: !!process.env.USERS_WEBHOOK_URL,
|
|
501
|
-
},
|
|
502
|
-
{
|
|
503
|
-
id: "wh_analytics",
|
|
504
|
-
url: "https://analytics.example.com/ingest",
|
|
505
|
-
events: ["INSERT", "UPDATE", "DELETE"],
|
|
506
|
-
table: "orders",
|
|
507
|
-
headers: { "Authorization": `Bearer ${process.env.ANALYTICS_API_KEY}` },
|
|
508
|
-
enabled: true,
|
|
509
|
-
},
|
|
510
|
-
];
|
|
511
|
-
|
|
512
|
-
dispatcher.setWebhooks(webhooks);
|
|
513
|
-
|
|
514
|
-
export { dispatcher };
|
|
515
|
-
```
|
|
516
|
-
|
|
517
|
-
Then import it from any callback or function:
|
|
518
|
-
|
|
519
|
-
```typescript
|
|
520
|
-
import { dispatcher } from "../lib/webhooks";
|
|
521
|
-
|
|
522
|
-
// In an entity callback:
|
|
523
|
-
afterCreate: async ({ entity, collection }) => {
|
|
524
|
-
await dispatcher.onEntityChange(collection.path, "INSERT", entity.id, entity);
|
|
525
|
-
},
|
|
526
|
-
```
|
|
527
|
-
|
|
528
|
-
## WebhookDispatcher API Reference
|
|
529
|
-
|
|
530
|
-
### `setWebhooks(webhooks: WebhookConfig[]): void`
|
|
531
|
-
|
|
532
|
-
Registers the list of webhooks to watch. Filters out any with `enabled: false`. **Replaces** the entire list — not additive.
|
|
533
|
-
|
|
534
|
-
| Parameter | Type | Description |
|
|
535
|
-
|-----------|------|-------------|
|
|
536
|
-
| `webhooks` | `WebhookConfig[]` | Array of webhook configurations. |
|
|
537
|
-
|
|
538
|
-
### `onEntityChange(table, event, entityId, entity, previousEntity?): Promise<WebhookDeliveryResult[]>`
|
|
539
|
-
|
|
540
|
-
Checks all registered webhooks for matching `table` + `event`, and dispatches to each match.
|
|
541
|
-
|
|
542
|
-
| Parameter | Type | Description |
|
|
543
|
-
|-----------|------|-------------|
|
|
544
|
-
| `table` | `string` | The database table name (e.g. `"orders"`). |
|
|
545
|
-
| `event` | `"INSERT" \| "UPDATE" \| "DELETE"` | The type of entity change. |
|
|
546
|
-
| `entityId` | `string` | The unique ID of the changed entity. |
|
|
547
|
-
| `entity` | `Record<string, unknown> \| null` | The current entity data. May be `null` for deletes. |
|
|
548
|
-
| `previousEntity` | `Record<string, unknown> \| null` | *(Optional)* The previous entity state. Only relevant for `UPDATE` events — included as `old_record` in the payload. |
|
|
549
|
-
|
|
550
|
-
**Returns:** `Promise<WebhookDeliveryResult[]>` — One result per matching webhook. Empty array if no webhooks match.
|
|
551
|
-
|
|
552
|
-
## Event Types
|
|
553
|
-
|
|
554
|
-
The dispatcher supports three event types, passed as the `event` parameter to `onEntityChange()`:
|
|
555
|
-
|
|
556
|
-
| Event | When to Use | `record` Contains | `old_record` Contains |
|
|
557
|
-
|-------|-------------|--------------------|-----------------------|
|
|
558
|
-
| `INSERT` | A new entity was created | The new entity | `undefined` |
|
|
559
|
-
| `UPDATE` | An existing entity was modified | The updated entity | The entity before update |
|
|
560
|
-
| `DELETE` | An entity was removed | The deleted entity (or `null`) | `undefined` |
|
|
561
|
-
|
|
562
|
-
## Error Handling
|
|
563
|
-
|
|
564
|
-
### Checking Delivery Results
|
|
565
|
-
|
|
566
|
-
```typescript
|
|
567
|
-
const results = await dispatcher.onEntityChange("orders", "INSERT", id, entity);
|
|
568
|
-
|
|
569
|
-
for (const result of results) {
|
|
570
|
-
if (!result.success) {
|
|
571
|
-
console.error(
|
|
572
|
-
`Webhook ${result.webhookId} failed after ${result.attemptNumber} attempts. ` +
|
|
573
|
-
`Status: ${result.statusCode}, Body: ${result.responseBody}`
|
|
574
|
-
);
|
|
575
|
-
}
|
|
576
|
-
}
|
|
577
|
-
```
|
|
578
|
-
|
|
579
|
-
### Failure Scenarios
|
|
580
|
-
|
|
581
|
-
| Scenario | `statusCode` | `responseBody` | `success` |
|
|
582
|
-
|----------|-------------|----------------|-----------|
|
|
583
|
-
| Receiver returns 200 | `200` | Response text (truncated to 1000 chars) | `true` |
|
|
584
|
-
| Receiver returns 500 | `500` | `"Internal Server Error"` | `false` |
|
|
585
|
-
| DNS resolution failure | `0` | `"getaddrinfo ENOTFOUND ..."` | `false` |
|
|
586
|
-
| Connection refused | `0` | `"connect ECONNREFUSED ..."` | `false` |
|
|
587
|
-
| 10s timeout exceeded | `0` | `"The operation was aborted"` | `false` |
|
|
588
|
-
| Network error | `0` | Error message (truncated to 1000 chars) | `false` |
|
|
589
|
-
|
|
590
|
-
### Non-Blocking Pattern
|
|
591
|
-
|
|
592
|
-
If you don't want webhook delivery to block your API response, use fire-and-forget:
|
|
593
|
-
|
|
594
|
-
```typescript
|
|
595
|
-
afterCreate: async ({ entity, collection }) => {
|
|
596
|
-
// Fire-and-forget — don't await
|
|
597
|
-
dispatcher.onEntityChange(collection.path, "INSERT", entity.id, entity)
|
|
598
|
-
.catch(err => console.error("Webhook dispatch error:", err));
|
|
599
|
-
},
|
|
600
|
-
```
|
|
601
|
-
|
|
602
|
-
## Edge Cases and Gotchas
|
|
603
|
-
|
|
604
|
-
| Scenario | Behavior |
|
|
605
|
-
|----------|----------|
|
|
606
|
-
| **No webhooks match the table/event** | Returns empty array `[]` immediately. No HTTP requests made. |
|
|
607
|
-
| **Webhook URL is unreachable** | Retries 3 times with backoff (1s, 5s delays). Final result has `statusCode: 0`. |
|
|
608
|
-
| **Receiver returns 4xx (e.g. 400, 404)** | Treated as failure and retried (no distinction between 4xx and 5xx). |
|
|
609
|
-
| **Receiver takes >10 seconds** | Request aborted via `AbortController`. Treated as failure, retried. |
|
|
610
|
-
| **Multiple webhooks match same event** | All matching webhooks are dispatched **sequentially** (not in parallel). |
|
|
611
|
-
| **Disabled webhook in `setWebhooks()`** | Silently filtered out. Never dispatched. |
|
|
612
|
-
| **`setWebhooks()` called multiple times** | Replaces the entire list each time. Previous webhooks are discarded. |
|
|
613
|
-
| **`secret` is not set** | No `X-Webhook-Signature` header is sent. Payload is not signed. |
|
|
614
|
-
| **Custom header conflicts with built-in** | Custom headers override built-in headers (they are spread after). |
|
|
615
|
-
| **Response body very large** | Truncated to **1000 characters** in the `WebhookDeliveryResult`. |
|
|
616
|
-
| **`entity` is `null` for DELETE** | Sent as `"record": null` in the payload. |
|
|
617
|
-
| **`previousEntity` not passed for UPDATE** | `old_record` is `undefined` (omitted from JSON). |
|
|
618
|
-
| **`onEntityChange` is async** | The entire retry sequence is awaited. For INSERT with 3 failed attempts: ~6s of waiting. |
|
|
619
|
-
|
|
620
|
-
## References
|
|
621
|
-
|
|
622
|
-
- **Documentation:** [rebase.pro/docs/backend/webhooks](https://rebase.pro/docs/backend/webhooks)
|
|
623
|
-
- **GitHub:** [github.com/rebasepro/rebase](https://github.com/rebasepro/rebase)
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
import * as dotenv from "dotenv";
|
|
2
|
-
import path from "path";
|
|
3
|
-
import { fileURLToPath } from "url";
|
|
4
|
-
import { defineConfig } from "drizzle-kit";
|
|
5
|
-
import { tables } from "./src/schema.generated";
|
|
6
|
-
import { getTableName } from "drizzle-orm";
|
|
7
|
-
import { getTableConfig, PgTable } from "drizzle-orm/pg-core";
|
|
8
|
-
|
|
9
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
10
|
-
const __dirname = path.dirname(__filename);
|
|
11
|
-
|
|
12
|
-
// Load .env from the project root (single file for the entire project)
|
|
13
|
-
dotenv.config({ path: path.resolve(__dirname, "../.env") });
|
|
14
|
-
|
|
15
|
-
if (!process.env.DATABASE_URL) {
|
|
16
|
-
throw new Error("DATABASE_URL is not set. Make sure .env file exists in the project root and contains DATABASE_URL");
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
// Extract table names from the generated schema, excluding system tables in the 'rebase' schema.
|
|
20
|
-
// This ensures drizzle-kit ONLY manages tables defined in the schema, and ignores the system tables
|
|
21
|
-
// managed by Rebase's own bootstrapper.
|
|
22
|
-
const tableNames = Object.values(tables)
|
|
23
|
-
.filter(table => getTableConfig(table as PgTable).schema !== "rebase")
|
|
24
|
-
.map(table => getTableName(table as PgTable));
|
|
25
|
-
|
|
26
|
-
// Dynamically extract all schemas defined in the generated tables (excluding 'rebase') to ensure Drizzle Kit manages them.
|
|
27
|
-
const schemas = Array.from(new Set(
|
|
28
|
-
Object.values(tables)
|
|
29
|
-
.map(table => getTableConfig(table as PgTable).schema || "public")
|
|
30
|
-
.filter(schema => schema !== "rebase")
|
|
31
|
-
));
|
|
32
|
-
|
|
33
|
-
export default defineConfig({
|
|
34
|
-
schema: "./src/schema.generated.ts",
|
|
35
|
-
out: "./drizzle",
|
|
36
|
-
dialect: "postgresql",
|
|
37
|
-
dbCredentials: {
|
|
38
|
-
url: process.env.DATABASE_URL
|
|
39
|
-
},
|
|
40
|
-
// Only manage tables defined in the generated schema.
|
|
41
|
-
// Unmapped tables in the database are completely ignored.
|
|
42
|
-
tablesFilter: tableNames,
|
|
43
|
-
// Restrict drizzle-kit to the schemas defined in our collections
|
|
44
|
-
schemaFilter: schemas.length > 0 ? schemas : ["public"],
|
|
45
|
-
// Prevent drizzle-kit from managing roles not defined in the schema
|
|
46
|
-
entities: {
|
|
47
|
-
roles: false
|
|
48
|
-
},
|
|
49
|
-
// If PostGIS or other extensions create helper tables, ignore them
|
|
50
|
-
extensionsFilters: ["postgis"]
|
|
51
|
-
});
|