@ravileal/event-hub 1.1.1
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 +78 -0
- package/dist/cjs/client.d.ts +64 -0
- package/dist/cjs/client.js +143 -0
- package/dist/cjs/headers.d.ts +28 -0
- package/dist/cjs/headers.js +30 -0
- package/dist/cjs/index.d.ts +11 -0
- package/dist/cjs/index.js +18 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/signature.d.ts +29 -0
- package/dist/cjs/signature.js +54 -0
- package/dist/cjs/types.d.ts +129 -0
- package/dist/cjs/types.js +6 -0
- package/dist/esm/client.d.ts +64 -0
- package/dist/esm/client.js +138 -0
- package/dist/esm/headers.d.ts +28 -0
- package/dist/esm/headers.js +27 -0
- package/dist/esm/index.d.ts +11 -0
- package/dist/esm/index.js +8 -0
- package/dist/esm/package.json +3 -0
- package/dist/esm/signature.d.ts +29 -0
- package/dist/esm/signature.js +49 -0
- package/dist/esm/types.d.ts +129 -0
- package/dist/esm/types.js +5 -0
- package/package.json +64 -0
package/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# @ravileal/event-hub
|
|
2
|
+
|
|
3
|
+
TypeScript client for [event-hub](https://github.com/ravileal/event-hub) — the webhook
|
|
4
|
+
fan-out hub (topics → subscribers → events → retries/deliveries).
|
|
5
|
+
|
|
6
|
+
- **Zero runtime dependencies** — native `fetch` (Node >= 18) and `node:crypto` only.
|
|
7
|
+
- **ESM + CJS + type declarations**.
|
|
8
|
+
- **Timing-safe HMAC delivery-signature verification**.
|
|
9
|
+
|
|
10
|
+
The package version matches the hub/image version (`ghcr.io/ravileal/event-hub`).
|
|
11
|
+
Because versions move together, the client sends the English `X-Idempotency`
|
|
12
|
+
header that hubs since 1.1.0 read; a hub older than 1.1.0 ignores it.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install @ravileal/event-hub
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Publish an event
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { EventHubClient } from '@ravileal/event-hub';
|
|
24
|
+
|
|
25
|
+
const hub = new EventHubClient({ baseUrl: 'http://127.0.0.1:18295' });
|
|
26
|
+
|
|
27
|
+
await hub.createTopic('orders');
|
|
28
|
+
await hub.createSubscriber('orders', {
|
|
29
|
+
name: 'billing',
|
|
30
|
+
url: 'http://billing.internal/hooks/orders',
|
|
31
|
+
secret: process.env.HUB_SECRET, // signs deliveries hub -> you
|
|
32
|
+
kind: 'webhook',
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// 202 for a fresh event, 200 when the idempotency key was already used.
|
|
36
|
+
const { status, data } = await hub.publishEvent('orders', { id: 1 }, { idempotencyKey: 'order-1' });
|
|
37
|
+
console.log(status, data.duplicado);
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Use `signPayload(secret, rawBody)` if you need to sign the exact bytes you send
|
|
41
|
+
(the hub's `X-Signature` on publish, or a delivery you replay).
|
|
42
|
+
|
|
43
|
+
## Verify an incoming delivery
|
|
44
|
+
|
|
45
|
+
Pass the **raw** body exactly as received; re-serializing parsed JSON changes the
|
|
46
|
+
bytes and fails verification.
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import { verifyDeliverySignature, HEADERS } from '@ravileal/event-hub';
|
|
50
|
+
|
|
51
|
+
// Express-style; the raw bytes matter, so use express.raw({ type: '*/*' }).
|
|
52
|
+
app.post('/hooks/orders', express.raw({ type: '*/*' }), (req, res) => {
|
|
53
|
+
const ok = verifyDeliverySignature(
|
|
54
|
+
process.env.HUB_SECRET!,
|
|
55
|
+
req.body, // Buffer = raw bytes
|
|
56
|
+
req.get(HEADERS.deliverySignature), // X-Event-Hub-Signature
|
|
57
|
+
);
|
|
58
|
+
if (!ok) return res.sendStatus(401);
|
|
59
|
+
res.sendStatus(200);
|
|
60
|
+
});
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`verifyDeliverySignature` returns `false` (never throws) for a missing header, a
|
|
64
|
+
wrong `sha256=` prefix, malformed hex or a length mismatch, and compares in
|
|
65
|
+
constant time.
|
|
66
|
+
|
|
67
|
+
## Notes
|
|
68
|
+
|
|
69
|
+
- The hub reads the idempotency header **`X-Idempotency`** (English) since 1.1.0 and
|
|
70
|
+
falls back to the legacy **`X-Idempotencia`**. `HEADERS.idempotency` and
|
|
71
|
+
`{ idempotencyKey }` send the English name; `HEADERS.legacyIdempotency` exposes the
|
|
72
|
+
alias the hub still accepts for old producers.
|
|
73
|
+
- Deliveries carry `X-Event-Hub-Event`, `X-Event-Hub-Attempt` and
|
|
74
|
+
`X-Event-Hub-Signature` (`sha256=<hex>` = HMAC-SHA256(raw body, subscriber secret)).
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
MIT
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { Delivery, DeliveryFilter, Health, IngestOptions, IngestResult, Metrics, PublishOptions, PublishResult, Subscriber, SubscriberSpec, Topic, TopicWithSubscribers } from './types.js';
|
|
2
|
+
/** Thrown for any non-2xx response from the hub. */
|
|
3
|
+
export declare class EventHubError extends Error {
|
|
4
|
+
readonly status: number;
|
|
5
|
+
readonly body: unknown;
|
|
6
|
+
constructor(status: number, body: unknown, message?: string);
|
|
7
|
+
}
|
|
8
|
+
/** Options for {@link EventHubClient}. `fetch` can be injected for testing. */
|
|
9
|
+
export interface EventHubClientOptions {
|
|
10
|
+
/** Base URL of the hub, e.g. `http://localhost:18295`. Trailing slashes trimmed. */
|
|
11
|
+
baseUrl?: string;
|
|
12
|
+
/** Custom fetch implementation; defaults to the global `fetch` (Node >= 18). */
|
|
13
|
+
fetch?: typeof fetch;
|
|
14
|
+
/** Headers merged into every request. */
|
|
15
|
+
defaultHeaders?: Record<string, string>;
|
|
16
|
+
}
|
|
17
|
+
/** Request body accepted by `publishEvent`: a JSON-able value or a raw string. */
|
|
18
|
+
export type EventPayload = unknown;
|
|
19
|
+
/**
|
|
20
|
+
* Typed, dependency-free client for the event-hub HTTP API.
|
|
21
|
+
*
|
|
22
|
+
* ```ts
|
|
23
|
+
* const hub = new EventHubClient({ baseUrl: 'http://localhost:18295' });
|
|
24
|
+
* const { status, data } = await hub.publishEvent('orders', { id: 1 }, { idempotencyKey: 'k1' });
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export declare class EventHubClient {
|
|
28
|
+
readonly baseUrl: string;
|
|
29
|
+
private readonly fetchImpl;
|
|
30
|
+
private readonly defaultHeaders;
|
|
31
|
+
constructor(options?: EventHubClientOptions);
|
|
32
|
+
private request;
|
|
33
|
+
/** `GET /health` */
|
|
34
|
+
health(): Promise<Health>;
|
|
35
|
+
/** `GET /metrics` */
|
|
36
|
+
metrics(): Promise<Metrics>;
|
|
37
|
+
/** `POST /topics` -> 201 Topic. */
|
|
38
|
+
createTopic(name: string, description?: string | null): Promise<Topic>;
|
|
39
|
+
/** `GET /topics` -> Topic[] with subscribers nested. */
|
|
40
|
+
listTopics(): Promise<TopicWithSubscribers[]>;
|
|
41
|
+
/** `POST /topics/{topic}/subscribers` -> 201 Subscriber. */
|
|
42
|
+
createSubscriber(topic: string, spec: SubscriberSpec): Promise<Subscriber>;
|
|
43
|
+
/**
|
|
44
|
+
* `POST /events/{topic}` -> 202 (fresh) or 200 (duplicate).
|
|
45
|
+
*
|
|
46
|
+
* A string payload is sent verbatim (so a pre-computed signature keeps
|
|
47
|
+
* matching); anything else is `JSON.stringify`-ed. When `signature` is
|
|
48
|
+
* provided it must sign the exact bytes that end up on the wire.
|
|
49
|
+
*/
|
|
50
|
+
publishEvent(topic: string, payload: EventPayload, options?: PublishOptions): Promise<PublishResult>;
|
|
51
|
+
/**
|
|
52
|
+
* `POST /ingest?origem=<name>` -> 202.
|
|
53
|
+
*
|
|
54
|
+
* Requires the hub to be running with `--routes`/`--config`; otherwise the
|
|
55
|
+
* hub answers 404 `{"error":"routing disabled..."}`.
|
|
56
|
+
*/
|
|
57
|
+
ingest(rawPayload: string, options?: IngestOptions): Promise<IngestResult>;
|
|
58
|
+
/** `GET /deliveries?status=&topic=&limite=` -> Delivery[]. */
|
|
59
|
+
listDeliveries(filter?: DeliveryFilter): Promise<Delivery[]>;
|
|
60
|
+
/** `POST /deliveries/{id}/retry` -> reschedules one delivery. */
|
|
61
|
+
retryDelivery(id: number): Promise<unknown>;
|
|
62
|
+
/** `POST /deliveries/retry-falhas` -> bulk retry of failed deliveries. */
|
|
63
|
+
retryFailedDeliveries(): Promise<unknown>;
|
|
64
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.EventHubClient = exports.EventHubError = void 0;
|
|
4
|
+
const headers_js_1 = require("./headers.js");
|
|
5
|
+
/** Thrown for any non-2xx response from the hub. */
|
|
6
|
+
class EventHubError extends Error {
|
|
7
|
+
status;
|
|
8
|
+
body;
|
|
9
|
+
constructor(status, body, message) {
|
|
10
|
+
super(message ?? `event-hub request failed with HTTP ${status}`);
|
|
11
|
+
this.name = 'EventHubError';
|
|
12
|
+
this.status = status;
|
|
13
|
+
this.body = body;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
exports.EventHubError = EventHubError;
|
|
17
|
+
const DEFAULT_BASE_URL = 'http://localhost:18295';
|
|
18
|
+
/**
|
|
19
|
+
* Typed, dependency-free client for the event-hub HTTP API.
|
|
20
|
+
*
|
|
21
|
+
* ```ts
|
|
22
|
+
* const hub = new EventHubClient({ baseUrl: 'http://localhost:18295' });
|
|
23
|
+
* const { status, data } = await hub.publishEvent('orders', { id: 1 }, { idempotencyKey: 'k1' });
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
class EventHubClient {
|
|
27
|
+
baseUrl;
|
|
28
|
+
fetchImpl;
|
|
29
|
+
defaultHeaders;
|
|
30
|
+
constructor(options = {}) {
|
|
31
|
+
this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
|
|
32
|
+
const f = options.fetch ?? globalThis.fetch;
|
|
33
|
+
if (typeof f !== 'function') {
|
|
34
|
+
throw new Error('EventHubClient: global fetch is unavailable; pass options.fetch (Node >= 18 required)');
|
|
35
|
+
}
|
|
36
|
+
this.fetchImpl = f;
|
|
37
|
+
this.defaultHeaders = options.defaultHeaders ?? {};
|
|
38
|
+
}
|
|
39
|
+
// ---------------------------------------------------------------- internal
|
|
40
|
+
async request(method, path, body, headers = {}) {
|
|
41
|
+
const init = {
|
|
42
|
+
method,
|
|
43
|
+
headers: { ...this.defaultHeaders, ...headers },
|
|
44
|
+
};
|
|
45
|
+
if (body !== undefined)
|
|
46
|
+
init.body = body;
|
|
47
|
+
const res = await this.fetchImpl(this.baseUrl + path, init);
|
|
48
|
+
const text = await res.text();
|
|
49
|
+
let parsed = null;
|
|
50
|
+
if (text.length > 0) {
|
|
51
|
+
try {
|
|
52
|
+
parsed = JSON.parse(text);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
parsed = text;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (!res.ok) {
|
|
59
|
+
throw new EventHubError(res.status, parsed, errorMessage(res.status, parsed));
|
|
60
|
+
}
|
|
61
|
+
return { status: res.status, data: parsed };
|
|
62
|
+
}
|
|
63
|
+
// ------------------------------------------------------------- public API
|
|
64
|
+
/** `GET /health` */
|
|
65
|
+
async health() {
|
|
66
|
+
return (await this.request('GET', '/health')).data;
|
|
67
|
+
}
|
|
68
|
+
/** `GET /metrics` */
|
|
69
|
+
async metrics() {
|
|
70
|
+
return (await this.request('GET', '/metrics')).data;
|
|
71
|
+
}
|
|
72
|
+
/** `POST /topics` -> 201 Topic. */
|
|
73
|
+
async createTopic(name, description) {
|
|
74
|
+
const body = JSON.stringify({ name, description: description ?? null });
|
|
75
|
+
return (await this.request('POST', '/topics', body, { 'Content-Type': 'application/json' })).data;
|
|
76
|
+
}
|
|
77
|
+
/** `GET /topics` -> Topic[] with subscribers nested. */
|
|
78
|
+
async listTopics() {
|
|
79
|
+
return (await this.request('GET', '/topics')).data;
|
|
80
|
+
}
|
|
81
|
+
/** `POST /topics/{topic}/subscribers` -> 201 Subscriber. */
|
|
82
|
+
async createSubscriber(topic, spec) {
|
|
83
|
+
const body = JSON.stringify({ kind: 'webhook', ...spec });
|
|
84
|
+
return (await this.request('POST', `/topics/${encodeURIComponent(topic)}/subscribers`, body, { 'Content-Type': 'application/json' })).data;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* `POST /events/{topic}` -> 202 (fresh) or 200 (duplicate).
|
|
88
|
+
*
|
|
89
|
+
* A string payload is sent verbatim (so a pre-computed signature keeps
|
|
90
|
+
* matching); anything else is `JSON.stringify`-ed. When `signature` is
|
|
91
|
+
* provided it must sign the exact bytes that end up on the wire.
|
|
92
|
+
*/
|
|
93
|
+
async publishEvent(topic, payload, options = {}) {
|
|
94
|
+
const body = typeof payload === 'string' ? payload : JSON.stringify(payload);
|
|
95
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
96
|
+
if (options.idempotencyKey !== undefined)
|
|
97
|
+
headers[headers_js_1.HEADERS.idempotency] = options.idempotencyKey;
|
|
98
|
+
if (options.signature !== undefined)
|
|
99
|
+
headers[headers_js_1.HEADERS.signature] = options.signature;
|
|
100
|
+
Object.assign(headers, options.headers ?? {});
|
|
101
|
+
const { status, data } = await this.request('POST', `/events/${encodeURIComponent(topic)}`, body, headers);
|
|
102
|
+
return { status, data };
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* `POST /ingest?origem=<name>` -> 202.
|
|
106
|
+
*
|
|
107
|
+
* Requires the hub to be running with `--routes`/`--config`; otherwise the
|
|
108
|
+
* hub answers 404 `{"error":"routing disabled..."}`.
|
|
109
|
+
*/
|
|
110
|
+
async ingest(rawPayload, options = {}) {
|
|
111
|
+
const qs = options.origem !== undefined ? `?origem=${encodeURIComponent(options.origem)}` : '';
|
|
112
|
+
return (await this.request('POST', `/ingest${qs}`, rawPayload, {
|
|
113
|
+
'Content-Type': 'application/json',
|
|
114
|
+
})).data;
|
|
115
|
+
}
|
|
116
|
+
/** `GET /deliveries?status=&topic=&limite=` -> Delivery[]. */
|
|
117
|
+
async listDeliveries(filter = {}) {
|
|
118
|
+
const params = new URLSearchParams();
|
|
119
|
+
if (filter.status !== undefined)
|
|
120
|
+
params.set('status', filter.status);
|
|
121
|
+
if (filter.topic !== undefined)
|
|
122
|
+
params.set('topic', filter.topic);
|
|
123
|
+
if (filter.limite !== undefined)
|
|
124
|
+
params.set('limite', String(filter.limite));
|
|
125
|
+
const qs = params.toString();
|
|
126
|
+
return (await this.request('GET', `/deliveries${qs ? `?${qs}` : ''}`)).data;
|
|
127
|
+
}
|
|
128
|
+
/** `POST /deliveries/{id}/retry` -> reschedules one delivery. */
|
|
129
|
+
async retryDelivery(id) {
|
|
130
|
+
return (await this.request('POST', `/deliveries/${id}/retry`)).data;
|
|
131
|
+
}
|
|
132
|
+
/** `POST /deliveries/retry-falhas` -> bulk retry of failed deliveries. */
|
|
133
|
+
async retryFailedDeliveries() {
|
|
134
|
+
return (await this.request('POST', '/deliveries/retry-falhas')).data;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
exports.EventHubClient = EventHubClient;
|
|
138
|
+
function errorMessage(status, body) {
|
|
139
|
+
if (body && typeof body === 'object' && 'error' in body && typeof body.error === 'string') {
|
|
140
|
+
return `event-hub HTTP ${status}: ${body.error}`;
|
|
141
|
+
}
|
|
142
|
+
return `event-hub request failed with HTTP ${status}`;
|
|
143
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical HTTP header names used by event-hub.
|
|
3
|
+
*
|
|
4
|
+
* Since hub 1.1.0 (src/api.rs) the inbound idempotency header is the English
|
|
5
|
+
* `X-Idempotency`. The hub still falls back to the legacy Portuguese
|
|
6
|
+
* `X-Idempotencia` for old producers, but new code should always send the
|
|
7
|
+
* English name.
|
|
8
|
+
*/
|
|
9
|
+
export declare const HEADERS: {
|
|
10
|
+
/** Inbound: idempotency key read by the hub (English, since hub 1.1.0). */
|
|
11
|
+
readonly idempotency: "X-Idempotency";
|
|
12
|
+
/**
|
|
13
|
+
* Inbound: legacy Portuguese alias, still accepted by the hub for old
|
|
14
|
+
* producers; do not send it from new code.
|
|
15
|
+
*/
|
|
16
|
+
readonly legacyIdempotency: "X-Idempotencia";
|
|
17
|
+
/** Inbound: `sha256=<hex>`, only checked when the hub has --hmac-secret. */
|
|
18
|
+
readonly signature: "X-Signature";
|
|
19
|
+
/** Delivery (hub -> consumer): event id. */
|
|
20
|
+
readonly deliveryEvent: "X-Event-Hub-Event";
|
|
21
|
+
/** Delivery: attempt number, 1-based. */
|
|
22
|
+
readonly deliveryAttempt: "X-Event-Hub-Attempt";
|
|
23
|
+
/** Delivery: `sha256=<hex>` HMAC-SHA256 of the raw body with the subscriber secret. */
|
|
24
|
+
readonly deliverySignature: "X-Event-Hub-Signature";
|
|
25
|
+
};
|
|
26
|
+
/** Prefix every signature emitted/consumed by event-hub carries. */
|
|
27
|
+
export declare const SIGNATURE_PREFIX = "sha256=";
|
|
28
|
+
export type HeaderName = (typeof HEADERS)[keyof typeof HEADERS];
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SIGNATURE_PREFIX = exports.HEADERS = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Canonical HTTP header names used by event-hub.
|
|
6
|
+
*
|
|
7
|
+
* Since hub 1.1.0 (src/api.rs) the inbound idempotency header is the English
|
|
8
|
+
* `X-Idempotency`. The hub still falls back to the legacy Portuguese
|
|
9
|
+
* `X-Idempotencia` for old producers, but new code should always send the
|
|
10
|
+
* English name.
|
|
11
|
+
*/
|
|
12
|
+
exports.HEADERS = {
|
|
13
|
+
/** Inbound: idempotency key read by the hub (English, since hub 1.1.0). */
|
|
14
|
+
idempotency: 'X-Idempotency',
|
|
15
|
+
/**
|
|
16
|
+
* Inbound: legacy Portuguese alias, still accepted by the hub for old
|
|
17
|
+
* producers; do not send it from new code.
|
|
18
|
+
*/
|
|
19
|
+
legacyIdempotency: 'X-Idempotencia',
|
|
20
|
+
/** Inbound: `sha256=<hex>`, only checked when the hub has --hmac-secret. */
|
|
21
|
+
signature: 'X-Signature',
|
|
22
|
+
/** Delivery (hub -> consumer): event id. */
|
|
23
|
+
deliveryEvent: 'X-Event-Hub-Event',
|
|
24
|
+
/** Delivery: attempt number, 1-based. */
|
|
25
|
+
deliveryAttempt: 'X-Event-Hub-Attempt',
|
|
26
|
+
/** Delivery: `sha256=<hex>` HMAC-SHA256 of the raw body with the subscriber secret. */
|
|
27
|
+
deliverySignature: 'X-Event-Hub-Signature',
|
|
28
|
+
};
|
|
29
|
+
/** Prefix every signature emitted/consumed by event-hub carries. */
|
|
30
|
+
exports.SIGNATURE_PREFIX = 'sha256=';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @ravileal/event-hub — TypeScript client for the event-hub webhook fan-out hub.
|
|
3
|
+
*
|
|
4
|
+
* Zero runtime dependencies: native `fetch` (Node >= 18) and `node:crypto`.
|
|
5
|
+
*/
|
|
6
|
+
export { EventHubClient, EventHubError } from './client.js';
|
|
7
|
+
export type { EventHubClientOptions, EventPayload } from './client.js';
|
|
8
|
+
export { verifyDeliverySignature, signPayload, computeDeliverySignature } from './signature.js';
|
|
9
|
+
export { HEADERS, SIGNATURE_PREFIX } from './headers.js';
|
|
10
|
+
export type { HeaderName } from './headers.js';
|
|
11
|
+
export type { AcceptedEvent, Delivery, DeliveryFilter, Health, IngestOptions, IngestResult, Metrics, PublishOptions, PublishResult, Subscriber, SubscriberSpec, SubscriberSummary, Topic, TopicWithSubscribers, } from './types.js';
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SIGNATURE_PREFIX = exports.HEADERS = exports.computeDeliverySignature = exports.signPayload = exports.verifyDeliverySignature = exports.EventHubError = exports.EventHubClient = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* @ravileal/event-hub — TypeScript client for the event-hub webhook fan-out hub.
|
|
6
|
+
*
|
|
7
|
+
* Zero runtime dependencies: native `fetch` (Node >= 18) and `node:crypto`.
|
|
8
|
+
*/
|
|
9
|
+
var client_js_1 = require("./client.js");
|
|
10
|
+
Object.defineProperty(exports, "EventHubClient", { enumerable: true, get: function () { return client_js_1.EventHubClient; } });
|
|
11
|
+
Object.defineProperty(exports, "EventHubError", { enumerable: true, get: function () { return client_js_1.EventHubError; } });
|
|
12
|
+
var signature_js_1 = require("./signature.js");
|
|
13
|
+
Object.defineProperty(exports, "verifyDeliverySignature", { enumerable: true, get: function () { return signature_js_1.verifyDeliverySignature; } });
|
|
14
|
+
Object.defineProperty(exports, "signPayload", { enumerable: true, get: function () { return signature_js_1.signPayload; } });
|
|
15
|
+
Object.defineProperty(exports, "computeDeliverySignature", { enumerable: true, get: function () { return signature_js_1.computeDeliverySignature; } });
|
|
16
|
+
var headers_js_1 = require("./headers.js");
|
|
17
|
+
Object.defineProperty(exports, "HEADERS", { enumerable: true, get: function () { return headers_js_1.HEADERS; } });
|
|
18
|
+
Object.defineProperty(exports, "SIGNATURE_PREFIX", { enumerable: true, get: function () { return headers_js_1.SIGNATURE_PREFIX; } });
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Computes the signature event-hub would put on a delivery for `rawBody` with
|
|
3
|
+
* `secret`: HMAC-SHA256 over the EXACT raw bytes, hex-encoded, prefixed with
|
|
4
|
+
* `sha256=`. Mirrors `sign()` in src/signature.rs.
|
|
5
|
+
*
|
|
6
|
+
* Useful for producers that sign inbound events (`X-Signature` on publish) and
|
|
7
|
+
* for consumers that want to verify a delivery by recomputing it. Accepts
|
|
8
|
+
* `string | Uint8Array` (a Node `Buffer` is a `Uint8Array`) so the emitted .d.ts
|
|
9
|
+
* does not require consumers to have @types/node.
|
|
10
|
+
*/
|
|
11
|
+
export declare function signPayload(secret: string | Uint8Array, rawBody: string | Uint8Array): string;
|
|
12
|
+
/**
|
|
13
|
+
* Alias of {@link signPayload} kept for the delivery-oriented call sites.
|
|
14
|
+
*
|
|
15
|
+
* @deprecated Use {@link signPayload}; this is the same function.
|
|
16
|
+
*/
|
|
17
|
+
export declare const computeDeliverySignature: typeof signPayload;
|
|
18
|
+
/**
|
|
19
|
+
* Verifies the `X-Event-Hub-Signature` header of a delivery against the raw
|
|
20
|
+
* request body and the subscriber secret, in constant time.
|
|
21
|
+
*
|
|
22
|
+
* Pass the RAW body exactly as received (string or bytes) — re-serializing
|
|
23
|
+
* parsed JSON will change the bytes and fail verification.
|
|
24
|
+
*
|
|
25
|
+
* Returns false (never throws) for a missing header, a wrong prefix, a
|
|
26
|
+
* malformed hex digest, or a length mismatch, so it is safe to call directly
|
|
27
|
+
* on untrusted input.
|
|
28
|
+
*/
|
|
29
|
+
export declare function verifyDeliverySignature(secret: string | Uint8Array, rawBody: string | Uint8Array, header: string | null | undefined): boolean;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.computeDeliverySignature = void 0;
|
|
4
|
+
exports.signPayload = signPayload;
|
|
5
|
+
exports.verifyDeliverySignature = verifyDeliverySignature;
|
|
6
|
+
const node_crypto_1 = require("node:crypto");
|
|
7
|
+
const headers_js_1 = require("./headers.js");
|
|
8
|
+
/**
|
|
9
|
+
* Computes the signature event-hub would put on a delivery for `rawBody` with
|
|
10
|
+
* `secret`: HMAC-SHA256 over the EXACT raw bytes, hex-encoded, prefixed with
|
|
11
|
+
* `sha256=`. Mirrors `sign()` in src/signature.rs.
|
|
12
|
+
*
|
|
13
|
+
* Useful for producers that sign inbound events (`X-Signature` on publish) and
|
|
14
|
+
* for consumers that want to verify a delivery by recomputing it. Accepts
|
|
15
|
+
* `string | Uint8Array` (a Node `Buffer` is a `Uint8Array`) so the emitted .d.ts
|
|
16
|
+
* does not require consumers to have @types/node.
|
|
17
|
+
*/
|
|
18
|
+
function signPayload(secret, rawBody) {
|
|
19
|
+
const mac = (0, node_crypto_1.createHmac)('sha256', secret);
|
|
20
|
+
mac.update(rawBody);
|
|
21
|
+
return headers_js_1.SIGNATURE_PREFIX + mac.digest('hex');
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Alias of {@link signPayload} kept for the delivery-oriented call sites.
|
|
25
|
+
*
|
|
26
|
+
* @deprecated Use {@link signPayload}; this is the same function.
|
|
27
|
+
*/
|
|
28
|
+
exports.computeDeliverySignature = signPayload;
|
|
29
|
+
const HEX_64 = /^[0-9a-f]{64}$/i;
|
|
30
|
+
/**
|
|
31
|
+
* Verifies the `X-Event-Hub-Signature` header of a delivery against the raw
|
|
32
|
+
* request body and the subscriber secret, in constant time.
|
|
33
|
+
*
|
|
34
|
+
* Pass the RAW body exactly as received (string or bytes) — re-serializing
|
|
35
|
+
* parsed JSON will change the bytes and fail verification.
|
|
36
|
+
*
|
|
37
|
+
* Returns false (never throws) for a missing header, a wrong prefix, a
|
|
38
|
+
* malformed hex digest, or a length mismatch, so it is safe to call directly
|
|
39
|
+
* on untrusted input.
|
|
40
|
+
*/
|
|
41
|
+
function verifyDeliverySignature(secret, rawBody, header) {
|
|
42
|
+
if (typeof header !== 'string' || header.length === 0)
|
|
43
|
+
return false;
|
|
44
|
+
if (!header.startsWith(headers_js_1.SIGNATURE_PREFIX))
|
|
45
|
+
return false;
|
|
46
|
+
const hex = header.slice(headers_js_1.SIGNATURE_PREFIX.length);
|
|
47
|
+
if (!HEX_64.test(hex))
|
|
48
|
+
return false;
|
|
49
|
+
const expected = (0, node_crypto_1.createHmac)('sha256', secret).update(rawBody).digest();
|
|
50
|
+
const received = Buffer.from(hex, 'hex');
|
|
51
|
+
if (expected.length !== received.length)
|
|
52
|
+
return false;
|
|
53
|
+
return (0, node_crypto_1.timingSafeEqual)(expected, received);
|
|
54
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire types mirroring the event-hub HTTP API (api.rs / models.rs).
|
|
3
|
+
* Field names are snake_case exactly as the hub serializes them.
|
|
4
|
+
*/
|
|
5
|
+
/** `POST /topics` -> 201 Topic */
|
|
6
|
+
export interface Topic {
|
|
7
|
+
id: number;
|
|
8
|
+
name: string;
|
|
9
|
+
description: string | null;
|
|
10
|
+
created_at: string;
|
|
11
|
+
}
|
|
12
|
+
/** Subscriber as nested inside `GET /topics` (SubscriberSummary in Rust). */
|
|
13
|
+
export interface SubscriberSummary {
|
|
14
|
+
id: number;
|
|
15
|
+
name: string;
|
|
16
|
+
url: string | null;
|
|
17
|
+
active: number;
|
|
18
|
+
max_attempts: number;
|
|
19
|
+
backoff_ms: number;
|
|
20
|
+
kind: string;
|
|
21
|
+
command: string | null;
|
|
22
|
+
timeout_ms: number | null;
|
|
23
|
+
}
|
|
24
|
+
/** `GET /topics` item: a Topic with its subscribers nested. */
|
|
25
|
+
export interface TopicWithSubscribers extends Topic {
|
|
26
|
+
subscribers: SubscriberSummary[];
|
|
27
|
+
}
|
|
28
|
+
/** `POST /topics/{topic}/subscribers` -> 201 (note: no `secret` echoed back). */
|
|
29
|
+
export interface Subscriber {
|
|
30
|
+
id: number;
|
|
31
|
+
topic_id: number;
|
|
32
|
+
name: string;
|
|
33
|
+
url: string | null;
|
|
34
|
+
active: number;
|
|
35
|
+
max_attempts: number;
|
|
36
|
+
backoff_ms: number;
|
|
37
|
+
kind: string;
|
|
38
|
+
command: string | null;
|
|
39
|
+
timeout_ms: number | null;
|
|
40
|
+
}
|
|
41
|
+
/** Body accepted by `POST /topics/{topic}/subscribers`. */
|
|
42
|
+
export interface SubscriberSpec {
|
|
43
|
+
name: string;
|
|
44
|
+
/** Required when kind === 'webhook'. */
|
|
45
|
+
url?: string;
|
|
46
|
+
/** HMAC secret used to sign deliveries hub -> consumer (X-Event-Hub-Signature). */
|
|
47
|
+
secret?: string;
|
|
48
|
+
max_attempts?: number;
|
|
49
|
+
backoff_ms?: number;
|
|
50
|
+
/** Defaults to 'webhook' on the hub. */
|
|
51
|
+
kind?: 'webhook' | 'command';
|
|
52
|
+
/** Required when kind === 'command'. */
|
|
53
|
+
command?: string;
|
|
54
|
+
timeout_ms?: number;
|
|
55
|
+
}
|
|
56
|
+
/** `POST /events/{topic}` response body (AcceptedEvent in Rust). */
|
|
57
|
+
export interface AcceptedEvent {
|
|
58
|
+
id: number;
|
|
59
|
+
deliveries_created: number;
|
|
60
|
+
duplicado: boolean;
|
|
61
|
+
}
|
|
62
|
+
/** Result of publishEvent: the HTTP status plus the parsed body. */
|
|
63
|
+
export interface PublishResult {
|
|
64
|
+
/** 202 for a fresh event, 200 when the idempotency key was already used. */
|
|
65
|
+
status: number;
|
|
66
|
+
data: AcceptedEvent;
|
|
67
|
+
}
|
|
68
|
+
/** `POST /ingest?origem=` response body. */
|
|
69
|
+
export interface IngestResult {
|
|
70
|
+
event_ids: number[];
|
|
71
|
+
topics: string[];
|
|
72
|
+
}
|
|
73
|
+
/** `GET /deliveries` item (DeliveryResumo in Rust). */
|
|
74
|
+
export interface Delivery {
|
|
75
|
+
id: number;
|
|
76
|
+
status: string;
|
|
77
|
+
attempts: number;
|
|
78
|
+
next_attempt_at: string | null;
|
|
79
|
+
last_error: string | null;
|
|
80
|
+
event_id: number;
|
|
81
|
+
topic: string;
|
|
82
|
+
subscriber: string;
|
|
83
|
+
url: string | null;
|
|
84
|
+
kind: string;
|
|
85
|
+
exit_code: number | null;
|
|
86
|
+
output: string | null;
|
|
87
|
+
}
|
|
88
|
+
/** `GET /deliveries` query filters. */
|
|
89
|
+
export interface DeliveryFilter {
|
|
90
|
+
status?: string;
|
|
91
|
+
topic?: string;
|
|
92
|
+
/** Clamped by the hub to 1..1000, default 50. */
|
|
93
|
+
limite?: number;
|
|
94
|
+
}
|
|
95
|
+
/** `GET /health` */
|
|
96
|
+
export interface Health {
|
|
97
|
+
ok: boolean;
|
|
98
|
+
version: string;
|
|
99
|
+
}
|
|
100
|
+
/** `GET /metrics` */
|
|
101
|
+
export interface Metrics {
|
|
102
|
+
events_total: number;
|
|
103
|
+
deliveries: Record<string, number>;
|
|
104
|
+
attempts_total: number;
|
|
105
|
+
topics: number;
|
|
106
|
+
subscribers: number;
|
|
107
|
+
proxied_total: number;
|
|
108
|
+
proxied: Record<string, number>;
|
|
109
|
+
}
|
|
110
|
+
/** Options for `publishEvent`. */
|
|
111
|
+
export interface PublishOptions {
|
|
112
|
+
/**
|
|
113
|
+
* Sets `X-Idempotency` (the header the hub reads since 1.1.0). The legacy
|
|
114
|
+
* `X-Idempotencia` spelling is still accepted by the hub for old producers,
|
|
115
|
+
* but do not send it from new code.
|
|
116
|
+
*/
|
|
117
|
+
idempotencyKey?: string;
|
|
118
|
+
/**
|
|
119
|
+
* Sets `X-Signature` (e.g. `sha256=<hex>`). Only enforced when the hub was
|
|
120
|
+
* started with an `--hmac-secret`; harmless otherwise.
|
|
121
|
+
*/
|
|
122
|
+
signature?: string;
|
|
123
|
+
/** Extra raw headers, merged last. */
|
|
124
|
+
headers?: Record<string, string>;
|
|
125
|
+
}
|
|
126
|
+
/** Options for `ingest`. */
|
|
127
|
+
export interface IngestOptions {
|
|
128
|
+
origem?: string;
|
|
129
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { Delivery, DeliveryFilter, Health, IngestOptions, IngestResult, Metrics, PublishOptions, PublishResult, Subscriber, SubscriberSpec, Topic, TopicWithSubscribers } from './types.js';
|
|
2
|
+
/** Thrown for any non-2xx response from the hub. */
|
|
3
|
+
export declare class EventHubError extends Error {
|
|
4
|
+
readonly status: number;
|
|
5
|
+
readonly body: unknown;
|
|
6
|
+
constructor(status: number, body: unknown, message?: string);
|
|
7
|
+
}
|
|
8
|
+
/** Options for {@link EventHubClient}. `fetch` can be injected for testing. */
|
|
9
|
+
export interface EventHubClientOptions {
|
|
10
|
+
/** Base URL of the hub, e.g. `http://localhost:18295`. Trailing slashes trimmed. */
|
|
11
|
+
baseUrl?: string;
|
|
12
|
+
/** Custom fetch implementation; defaults to the global `fetch` (Node >= 18). */
|
|
13
|
+
fetch?: typeof fetch;
|
|
14
|
+
/** Headers merged into every request. */
|
|
15
|
+
defaultHeaders?: Record<string, string>;
|
|
16
|
+
}
|
|
17
|
+
/** Request body accepted by `publishEvent`: a JSON-able value or a raw string. */
|
|
18
|
+
export type EventPayload = unknown;
|
|
19
|
+
/**
|
|
20
|
+
* Typed, dependency-free client for the event-hub HTTP API.
|
|
21
|
+
*
|
|
22
|
+
* ```ts
|
|
23
|
+
* const hub = new EventHubClient({ baseUrl: 'http://localhost:18295' });
|
|
24
|
+
* const { status, data } = await hub.publishEvent('orders', { id: 1 }, { idempotencyKey: 'k1' });
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export declare class EventHubClient {
|
|
28
|
+
readonly baseUrl: string;
|
|
29
|
+
private readonly fetchImpl;
|
|
30
|
+
private readonly defaultHeaders;
|
|
31
|
+
constructor(options?: EventHubClientOptions);
|
|
32
|
+
private request;
|
|
33
|
+
/** `GET /health` */
|
|
34
|
+
health(): Promise<Health>;
|
|
35
|
+
/** `GET /metrics` */
|
|
36
|
+
metrics(): Promise<Metrics>;
|
|
37
|
+
/** `POST /topics` -> 201 Topic. */
|
|
38
|
+
createTopic(name: string, description?: string | null): Promise<Topic>;
|
|
39
|
+
/** `GET /topics` -> Topic[] with subscribers nested. */
|
|
40
|
+
listTopics(): Promise<TopicWithSubscribers[]>;
|
|
41
|
+
/** `POST /topics/{topic}/subscribers` -> 201 Subscriber. */
|
|
42
|
+
createSubscriber(topic: string, spec: SubscriberSpec): Promise<Subscriber>;
|
|
43
|
+
/**
|
|
44
|
+
* `POST /events/{topic}` -> 202 (fresh) or 200 (duplicate).
|
|
45
|
+
*
|
|
46
|
+
* A string payload is sent verbatim (so a pre-computed signature keeps
|
|
47
|
+
* matching); anything else is `JSON.stringify`-ed. When `signature` is
|
|
48
|
+
* provided it must sign the exact bytes that end up on the wire.
|
|
49
|
+
*/
|
|
50
|
+
publishEvent(topic: string, payload: EventPayload, options?: PublishOptions): Promise<PublishResult>;
|
|
51
|
+
/**
|
|
52
|
+
* `POST /ingest?origem=<name>` -> 202.
|
|
53
|
+
*
|
|
54
|
+
* Requires the hub to be running with `--routes`/`--config`; otherwise the
|
|
55
|
+
* hub answers 404 `{"error":"routing disabled..."}`.
|
|
56
|
+
*/
|
|
57
|
+
ingest(rawPayload: string, options?: IngestOptions): Promise<IngestResult>;
|
|
58
|
+
/** `GET /deliveries?status=&topic=&limite=` -> Delivery[]. */
|
|
59
|
+
listDeliveries(filter?: DeliveryFilter): Promise<Delivery[]>;
|
|
60
|
+
/** `POST /deliveries/{id}/retry` -> reschedules one delivery. */
|
|
61
|
+
retryDelivery(id: number): Promise<unknown>;
|
|
62
|
+
/** `POST /deliveries/retry-falhas` -> bulk retry of failed deliveries. */
|
|
63
|
+
retryFailedDeliveries(): Promise<unknown>;
|
|
64
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { HEADERS } from './headers.js';
|
|
2
|
+
/** Thrown for any non-2xx response from the hub. */
|
|
3
|
+
export class EventHubError extends Error {
|
|
4
|
+
status;
|
|
5
|
+
body;
|
|
6
|
+
constructor(status, body, message) {
|
|
7
|
+
super(message ?? `event-hub request failed with HTTP ${status}`);
|
|
8
|
+
this.name = 'EventHubError';
|
|
9
|
+
this.status = status;
|
|
10
|
+
this.body = body;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
const DEFAULT_BASE_URL = 'http://localhost:18295';
|
|
14
|
+
/**
|
|
15
|
+
* Typed, dependency-free client for the event-hub HTTP API.
|
|
16
|
+
*
|
|
17
|
+
* ```ts
|
|
18
|
+
* const hub = new EventHubClient({ baseUrl: 'http://localhost:18295' });
|
|
19
|
+
* const { status, data } = await hub.publishEvent('orders', { id: 1 }, { idempotencyKey: 'k1' });
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export class EventHubClient {
|
|
23
|
+
baseUrl;
|
|
24
|
+
fetchImpl;
|
|
25
|
+
defaultHeaders;
|
|
26
|
+
constructor(options = {}) {
|
|
27
|
+
this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
|
|
28
|
+
const f = options.fetch ?? globalThis.fetch;
|
|
29
|
+
if (typeof f !== 'function') {
|
|
30
|
+
throw new Error('EventHubClient: global fetch is unavailable; pass options.fetch (Node >= 18 required)');
|
|
31
|
+
}
|
|
32
|
+
this.fetchImpl = f;
|
|
33
|
+
this.defaultHeaders = options.defaultHeaders ?? {};
|
|
34
|
+
}
|
|
35
|
+
// ---------------------------------------------------------------- internal
|
|
36
|
+
async request(method, path, body, headers = {}) {
|
|
37
|
+
const init = {
|
|
38
|
+
method,
|
|
39
|
+
headers: { ...this.defaultHeaders, ...headers },
|
|
40
|
+
};
|
|
41
|
+
if (body !== undefined)
|
|
42
|
+
init.body = body;
|
|
43
|
+
const res = await this.fetchImpl(this.baseUrl + path, init);
|
|
44
|
+
const text = await res.text();
|
|
45
|
+
let parsed = null;
|
|
46
|
+
if (text.length > 0) {
|
|
47
|
+
try {
|
|
48
|
+
parsed = JSON.parse(text);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
parsed = text;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (!res.ok) {
|
|
55
|
+
throw new EventHubError(res.status, parsed, errorMessage(res.status, parsed));
|
|
56
|
+
}
|
|
57
|
+
return { status: res.status, data: parsed };
|
|
58
|
+
}
|
|
59
|
+
// ------------------------------------------------------------- public API
|
|
60
|
+
/** `GET /health` */
|
|
61
|
+
async health() {
|
|
62
|
+
return (await this.request('GET', '/health')).data;
|
|
63
|
+
}
|
|
64
|
+
/** `GET /metrics` */
|
|
65
|
+
async metrics() {
|
|
66
|
+
return (await this.request('GET', '/metrics')).data;
|
|
67
|
+
}
|
|
68
|
+
/** `POST /topics` -> 201 Topic. */
|
|
69
|
+
async createTopic(name, description) {
|
|
70
|
+
const body = JSON.stringify({ name, description: description ?? null });
|
|
71
|
+
return (await this.request('POST', '/topics', body, { 'Content-Type': 'application/json' })).data;
|
|
72
|
+
}
|
|
73
|
+
/** `GET /topics` -> Topic[] with subscribers nested. */
|
|
74
|
+
async listTopics() {
|
|
75
|
+
return (await this.request('GET', '/topics')).data;
|
|
76
|
+
}
|
|
77
|
+
/** `POST /topics/{topic}/subscribers` -> 201 Subscriber. */
|
|
78
|
+
async createSubscriber(topic, spec) {
|
|
79
|
+
const body = JSON.stringify({ kind: 'webhook', ...spec });
|
|
80
|
+
return (await this.request('POST', `/topics/${encodeURIComponent(topic)}/subscribers`, body, { 'Content-Type': 'application/json' })).data;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* `POST /events/{topic}` -> 202 (fresh) or 200 (duplicate).
|
|
84
|
+
*
|
|
85
|
+
* A string payload is sent verbatim (so a pre-computed signature keeps
|
|
86
|
+
* matching); anything else is `JSON.stringify`-ed. When `signature` is
|
|
87
|
+
* provided it must sign the exact bytes that end up on the wire.
|
|
88
|
+
*/
|
|
89
|
+
async publishEvent(topic, payload, options = {}) {
|
|
90
|
+
const body = typeof payload === 'string' ? payload : JSON.stringify(payload);
|
|
91
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
92
|
+
if (options.idempotencyKey !== undefined)
|
|
93
|
+
headers[HEADERS.idempotency] = options.idempotencyKey;
|
|
94
|
+
if (options.signature !== undefined)
|
|
95
|
+
headers[HEADERS.signature] = options.signature;
|
|
96
|
+
Object.assign(headers, options.headers ?? {});
|
|
97
|
+
const { status, data } = await this.request('POST', `/events/${encodeURIComponent(topic)}`, body, headers);
|
|
98
|
+
return { status, data };
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* `POST /ingest?origem=<name>` -> 202.
|
|
102
|
+
*
|
|
103
|
+
* Requires the hub to be running with `--routes`/`--config`; otherwise the
|
|
104
|
+
* hub answers 404 `{"error":"routing disabled..."}`.
|
|
105
|
+
*/
|
|
106
|
+
async ingest(rawPayload, options = {}) {
|
|
107
|
+
const qs = options.origem !== undefined ? `?origem=${encodeURIComponent(options.origem)}` : '';
|
|
108
|
+
return (await this.request('POST', `/ingest${qs}`, rawPayload, {
|
|
109
|
+
'Content-Type': 'application/json',
|
|
110
|
+
})).data;
|
|
111
|
+
}
|
|
112
|
+
/** `GET /deliveries?status=&topic=&limite=` -> Delivery[]. */
|
|
113
|
+
async listDeliveries(filter = {}) {
|
|
114
|
+
const params = new URLSearchParams();
|
|
115
|
+
if (filter.status !== undefined)
|
|
116
|
+
params.set('status', filter.status);
|
|
117
|
+
if (filter.topic !== undefined)
|
|
118
|
+
params.set('topic', filter.topic);
|
|
119
|
+
if (filter.limite !== undefined)
|
|
120
|
+
params.set('limite', String(filter.limite));
|
|
121
|
+
const qs = params.toString();
|
|
122
|
+
return (await this.request('GET', `/deliveries${qs ? `?${qs}` : ''}`)).data;
|
|
123
|
+
}
|
|
124
|
+
/** `POST /deliveries/{id}/retry` -> reschedules one delivery. */
|
|
125
|
+
async retryDelivery(id) {
|
|
126
|
+
return (await this.request('POST', `/deliveries/${id}/retry`)).data;
|
|
127
|
+
}
|
|
128
|
+
/** `POST /deliveries/retry-falhas` -> bulk retry of failed deliveries. */
|
|
129
|
+
async retryFailedDeliveries() {
|
|
130
|
+
return (await this.request('POST', '/deliveries/retry-falhas')).data;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function errorMessage(status, body) {
|
|
134
|
+
if (body && typeof body === 'object' && 'error' in body && typeof body.error === 'string') {
|
|
135
|
+
return `event-hub HTTP ${status}: ${body.error}`;
|
|
136
|
+
}
|
|
137
|
+
return `event-hub request failed with HTTP ${status}`;
|
|
138
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical HTTP header names used by event-hub.
|
|
3
|
+
*
|
|
4
|
+
* Since hub 1.1.0 (src/api.rs) the inbound idempotency header is the English
|
|
5
|
+
* `X-Idempotency`. The hub still falls back to the legacy Portuguese
|
|
6
|
+
* `X-Idempotencia` for old producers, but new code should always send the
|
|
7
|
+
* English name.
|
|
8
|
+
*/
|
|
9
|
+
export declare const HEADERS: {
|
|
10
|
+
/** Inbound: idempotency key read by the hub (English, since hub 1.1.0). */
|
|
11
|
+
readonly idempotency: "X-Idempotency";
|
|
12
|
+
/**
|
|
13
|
+
* Inbound: legacy Portuguese alias, still accepted by the hub for old
|
|
14
|
+
* producers; do not send it from new code.
|
|
15
|
+
*/
|
|
16
|
+
readonly legacyIdempotency: "X-Idempotencia";
|
|
17
|
+
/** Inbound: `sha256=<hex>`, only checked when the hub has --hmac-secret. */
|
|
18
|
+
readonly signature: "X-Signature";
|
|
19
|
+
/** Delivery (hub -> consumer): event id. */
|
|
20
|
+
readonly deliveryEvent: "X-Event-Hub-Event";
|
|
21
|
+
/** Delivery: attempt number, 1-based. */
|
|
22
|
+
readonly deliveryAttempt: "X-Event-Hub-Attempt";
|
|
23
|
+
/** Delivery: `sha256=<hex>` HMAC-SHA256 of the raw body with the subscriber secret. */
|
|
24
|
+
readonly deliverySignature: "X-Event-Hub-Signature";
|
|
25
|
+
};
|
|
26
|
+
/** Prefix every signature emitted/consumed by event-hub carries. */
|
|
27
|
+
export declare const SIGNATURE_PREFIX = "sha256=";
|
|
28
|
+
export type HeaderName = (typeof HEADERS)[keyof typeof HEADERS];
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical HTTP header names used by event-hub.
|
|
3
|
+
*
|
|
4
|
+
* Since hub 1.1.0 (src/api.rs) the inbound idempotency header is the English
|
|
5
|
+
* `X-Idempotency`. The hub still falls back to the legacy Portuguese
|
|
6
|
+
* `X-Idempotencia` for old producers, but new code should always send the
|
|
7
|
+
* English name.
|
|
8
|
+
*/
|
|
9
|
+
export const HEADERS = {
|
|
10
|
+
/** Inbound: idempotency key read by the hub (English, since hub 1.1.0). */
|
|
11
|
+
idempotency: 'X-Idempotency',
|
|
12
|
+
/**
|
|
13
|
+
* Inbound: legacy Portuguese alias, still accepted by the hub for old
|
|
14
|
+
* producers; do not send it from new code.
|
|
15
|
+
*/
|
|
16
|
+
legacyIdempotency: 'X-Idempotencia',
|
|
17
|
+
/** Inbound: `sha256=<hex>`, only checked when the hub has --hmac-secret. */
|
|
18
|
+
signature: 'X-Signature',
|
|
19
|
+
/** Delivery (hub -> consumer): event id. */
|
|
20
|
+
deliveryEvent: 'X-Event-Hub-Event',
|
|
21
|
+
/** Delivery: attempt number, 1-based. */
|
|
22
|
+
deliveryAttempt: 'X-Event-Hub-Attempt',
|
|
23
|
+
/** Delivery: `sha256=<hex>` HMAC-SHA256 of the raw body with the subscriber secret. */
|
|
24
|
+
deliverySignature: 'X-Event-Hub-Signature',
|
|
25
|
+
};
|
|
26
|
+
/** Prefix every signature emitted/consumed by event-hub carries. */
|
|
27
|
+
export const SIGNATURE_PREFIX = 'sha256=';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @ravileal/event-hub — TypeScript client for the event-hub webhook fan-out hub.
|
|
3
|
+
*
|
|
4
|
+
* Zero runtime dependencies: native `fetch` (Node >= 18) and `node:crypto`.
|
|
5
|
+
*/
|
|
6
|
+
export { EventHubClient, EventHubError } from './client.js';
|
|
7
|
+
export type { EventHubClientOptions, EventPayload } from './client.js';
|
|
8
|
+
export { verifyDeliverySignature, signPayload, computeDeliverySignature } from './signature.js';
|
|
9
|
+
export { HEADERS, SIGNATURE_PREFIX } from './headers.js';
|
|
10
|
+
export type { HeaderName } from './headers.js';
|
|
11
|
+
export type { AcceptedEvent, Delivery, DeliveryFilter, Health, IngestOptions, IngestResult, Metrics, PublishOptions, PublishResult, Subscriber, SubscriberSpec, SubscriberSummary, Topic, TopicWithSubscribers, } from './types.js';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @ravileal/event-hub — TypeScript client for the event-hub webhook fan-out hub.
|
|
3
|
+
*
|
|
4
|
+
* Zero runtime dependencies: native `fetch` (Node >= 18) and `node:crypto`.
|
|
5
|
+
*/
|
|
6
|
+
export { EventHubClient, EventHubError } from './client.js';
|
|
7
|
+
export { verifyDeliverySignature, signPayload, computeDeliverySignature } from './signature.js';
|
|
8
|
+
export { HEADERS, SIGNATURE_PREFIX } from './headers.js';
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Computes the signature event-hub would put on a delivery for `rawBody` with
|
|
3
|
+
* `secret`: HMAC-SHA256 over the EXACT raw bytes, hex-encoded, prefixed with
|
|
4
|
+
* `sha256=`. Mirrors `sign()` in src/signature.rs.
|
|
5
|
+
*
|
|
6
|
+
* Useful for producers that sign inbound events (`X-Signature` on publish) and
|
|
7
|
+
* for consumers that want to verify a delivery by recomputing it. Accepts
|
|
8
|
+
* `string | Uint8Array` (a Node `Buffer` is a `Uint8Array`) so the emitted .d.ts
|
|
9
|
+
* does not require consumers to have @types/node.
|
|
10
|
+
*/
|
|
11
|
+
export declare function signPayload(secret: string | Uint8Array, rawBody: string | Uint8Array): string;
|
|
12
|
+
/**
|
|
13
|
+
* Alias of {@link signPayload} kept for the delivery-oriented call sites.
|
|
14
|
+
*
|
|
15
|
+
* @deprecated Use {@link signPayload}; this is the same function.
|
|
16
|
+
*/
|
|
17
|
+
export declare const computeDeliverySignature: typeof signPayload;
|
|
18
|
+
/**
|
|
19
|
+
* Verifies the `X-Event-Hub-Signature` header of a delivery against the raw
|
|
20
|
+
* request body and the subscriber secret, in constant time.
|
|
21
|
+
*
|
|
22
|
+
* Pass the RAW body exactly as received (string or bytes) — re-serializing
|
|
23
|
+
* parsed JSON will change the bytes and fail verification.
|
|
24
|
+
*
|
|
25
|
+
* Returns false (never throws) for a missing header, a wrong prefix, a
|
|
26
|
+
* malformed hex digest, or a length mismatch, so it is safe to call directly
|
|
27
|
+
* on untrusted input.
|
|
28
|
+
*/
|
|
29
|
+
export declare function verifyDeliverySignature(secret: string | Uint8Array, rawBody: string | Uint8Array, header: string | null | undefined): boolean;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { SIGNATURE_PREFIX } from './headers.js';
|
|
3
|
+
/**
|
|
4
|
+
* Computes the signature event-hub would put on a delivery for `rawBody` with
|
|
5
|
+
* `secret`: HMAC-SHA256 over the EXACT raw bytes, hex-encoded, prefixed with
|
|
6
|
+
* `sha256=`. Mirrors `sign()` in src/signature.rs.
|
|
7
|
+
*
|
|
8
|
+
* Useful for producers that sign inbound events (`X-Signature` on publish) and
|
|
9
|
+
* for consumers that want to verify a delivery by recomputing it. Accepts
|
|
10
|
+
* `string | Uint8Array` (a Node `Buffer` is a `Uint8Array`) so the emitted .d.ts
|
|
11
|
+
* does not require consumers to have @types/node.
|
|
12
|
+
*/
|
|
13
|
+
export function signPayload(secret, rawBody) {
|
|
14
|
+
const mac = createHmac('sha256', secret);
|
|
15
|
+
mac.update(rawBody);
|
|
16
|
+
return SIGNATURE_PREFIX + mac.digest('hex');
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Alias of {@link signPayload} kept for the delivery-oriented call sites.
|
|
20
|
+
*
|
|
21
|
+
* @deprecated Use {@link signPayload}; this is the same function.
|
|
22
|
+
*/
|
|
23
|
+
export const computeDeliverySignature = signPayload;
|
|
24
|
+
const HEX_64 = /^[0-9a-f]{64}$/i;
|
|
25
|
+
/**
|
|
26
|
+
* Verifies the `X-Event-Hub-Signature` header of a delivery against the raw
|
|
27
|
+
* request body and the subscriber secret, in constant time.
|
|
28
|
+
*
|
|
29
|
+
* Pass the RAW body exactly as received (string or bytes) — re-serializing
|
|
30
|
+
* parsed JSON will change the bytes and fail verification.
|
|
31
|
+
*
|
|
32
|
+
* Returns false (never throws) for a missing header, a wrong prefix, a
|
|
33
|
+
* malformed hex digest, or a length mismatch, so it is safe to call directly
|
|
34
|
+
* on untrusted input.
|
|
35
|
+
*/
|
|
36
|
+
export function verifyDeliverySignature(secret, rawBody, header) {
|
|
37
|
+
if (typeof header !== 'string' || header.length === 0)
|
|
38
|
+
return false;
|
|
39
|
+
if (!header.startsWith(SIGNATURE_PREFIX))
|
|
40
|
+
return false;
|
|
41
|
+
const hex = header.slice(SIGNATURE_PREFIX.length);
|
|
42
|
+
if (!HEX_64.test(hex))
|
|
43
|
+
return false;
|
|
44
|
+
const expected = createHmac('sha256', secret).update(rawBody).digest();
|
|
45
|
+
const received = Buffer.from(hex, 'hex');
|
|
46
|
+
if (expected.length !== received.length)
|
|
47
|
+
return false;
|
|
48
|
+
return timingSafeEqual(expected, received);
|
|
49
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire types mirroring the event-hub HTTP API (api.rs / models.rs).
|
|
3
|
+
* Field names are snake_case exactly as the hub serializes them.
|
|
4
|
+
*/
|
|
5
|
+
/** `POST /topics` -> 201 Topic */
|
|
6
|
+
export interface Topic {
|
|
7
|
+
id: number;
|
|
8
|
+
name: string;
|
|
9
|
+
description: string | null;
|
|
10
|
+
created_at: string;
|
|
11
|
+
}
|
|
12
|
+
/** Subscriber as nested inside `GET /topics` (SubscriberSummary in Rust). */
|
|
13
|
+
export interface SubscriberSummary {
|
|
14
|
+
id: number;
|
|
15
|
+
name: string;
|
|
16
|
+
url: string | null;
|
|
17
|
+
active: number;
|
|
18
|
+
max_attempts: number;
|
|
19
|
+
backoff_ms: number;
|
|
20
|
+
kind: string;
|
|
21
|
+
command: string | null;
|
|
22
|
+
timeout_ms: number | null;
|
|
23
|
+
}
|
|
24
|
+
/** `GET /topics` item: a Topic with its subscribers nested. */
|
|
25
|
+
export interface TopicWithSubscribers extends Topic {
|
|
26
|
+
subscribers: SubscriberSummary[];
|
|
27
|
+
}
|
|
28
|
+
/** `POST /topics/{topic}/subscribers` -> 201 (note: no `secret` echoed back). */
|
|
29
|
+
export interface Subscriber {
|
|
30
|
+
id: number;
|
|
31
|
+
topic_id: number;
|
|
32
|
+
name: string;
|
|
33
|
+
url: string | null;
|
|
34
|
+
active: number;
|
|
35
|
+
max_attempts: number;
|
|
36
|
+
backoff_ms: number;
|
|
37
|
+
kind: string;
|
|
38
|
+
command: string | null;
|
|
39
|
+
timeout_ms: number | null;
|
|
40
|
+
}
|
|
41
|
+
/** Body accepted by `POST /topics/{topic}/subscribers`. */
|
|
42
|
+
export interface SubscriberSpec {
|
|
43
|
+
name: string;
|
|
44
|
+
/** Required when kind === 'webhook'. */
|
|
45
|
+
url?: string;
|
|
46
|
+
/** HMAC secret used to sign deliveries hub -> consumer (X-Event-Hub-Signature). */
|
|
47
|
+
secret?: string;
|
|
48
|
+
max_attempts?: number;
|
|
49
|
+
backoff_ms?: number;
|
|
50
|
+
/** Defaults to 'webhook' on the hub. */
|
|
51
|
+
kind?: 'webhook' | 'command';
|
|
52
|
+
/** Required when kind === 'command'. */
|
|
53
|
+
command?: string;
|
|
54
|
+
timeout_ms?: number;
|
|
55
|
+
}
|
|
56
|
+
/** `POST /events/{topic}` response body (AcceptedEvent in Rust). */
|
|
57
|
+
export interface AcceptedEvent {
|
|
58
|
+
id: number;
|
|
59
|
+
deliveries_created: number;
|
|
60
|
+
duplicado: boolean;
|
|
61
|
+
}
|
|
62
|
+
/** Result of publishEvent: the HTTP status plus the parsed body. */
|
|
63
|
+
export interface PublishResult {
|
|
64
|
+
/** 202 for a fresh event, 200 when the idempotency key was already used. */
|
|
65
|
+
status: number;
|
|
66
|
+
data: AcceptedEvent;
|
|
67
|
+
}
|
|
68
|
+
/** `POST /ingest?origem=` response body. */
|
|
69
|
+
export interface IngestResult {
|
|
70
|
+
event_ids: number[];
|
|
71
|
+
topics: string[];
|
|
72
|
+
}
|
|
73
|
+
/** `GET /deliveries` item (DeliveryResumo in Rust). */
|
|
74
|
+
export interface Delivery {
|
|
75
|
+
id: number;
|
|
76
|
+
status: string;
|
|
77
|
+
attempts: number;
|
|
78
|
+
next_attempt_at: string | null;
|
|
79
|
+
last_error: string | null;
|
|
80
|
+
event_id: number;
|
|
81
|
+
topic: string;
|
|
82
|
+
subscriber: string;
|
|
83
|
+
url: string | null;
|
|
84
|
+
kind: string;
|
|
85
|
+
exit_code: number | null;
|
|
86
|
+
output: string | null;
|
|
87
|
+
}
|
|
88
|
+
/** `GET /deliveries` query filters. */
|
|
89
|
+
export interface DeliveryFilter {
|
|
90
|
+
status?: string;
|
|
91
|
+
topic?: string;
|
|
92
|
+
/** Clamped by the hub to 1..1000, default 50. */
|
|
93
|
+
limite?: number;
|
|
94
|
+
}
|
|
95
|
+
/** `GET /health` */
|
|
96
|
+
export interface Health {
|
|
97
|
+
ok: boolean;
|
|
98
|
+
version: string;
|
|
99
|
+
}
|
|
100
|
+
/** `GET /metrics` */
|
|
101
|
+
export interface Metrics {
|
|
102
|
+
events_total: number;
|
|
103
|
+
deliveries: Record<string, number>;
|
|
104
|
+
attempts_total: number;
|
|
105
|
+
topics: number;
|
|
106
|
+
subscribers: number;
|
|
107
|
+
proxied_total: number;
|
|
108
|
+
proxied: Record<string, number>;
|
|
109
|
+
}
|
|
110
|
+
/** Options for `publishEvent`. */
|
|
111
|
+
export interface PublishOptions {
|
|
112
|
+
/**
|
|
113
|
+
* Sets `X-Idempotency` (the header the hub reads since 1.1.0). The legacy
|
|
114
|
+
* `X-Idempotencia` spelling is still accepted by the hub for old producers,
|
|
115
|
+
* but do not send it from new code.
|
|
116
|
+
*/
|
|
117
|
+
idempotencyKey?: string;
|
|
118
|
+
/**
|
|
119
|
+
* Sets `X-Signature` (e.g. `sha256=<hex>`). Only enforced when the hub was
|
|
120
|
+
* started with an `--hmac-secret`; harmless otherwise.
|
|
121
|
+
*/
|
|
122
|
+
signature?: string;
|
|
123
|
+
/** Extra raw headers, merged last. */
|
|
124
|
+
headers?: Record<string, string>;
|
|
125
|
+
}
|
|
126
|
+
/** Options for `ingest`. */
|
|
127
|
+
export interface IngestOptions {
|
|
128
|
+
origem?: string;
|
|
129
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ravileal/event-hub",
|
|
3
|
+
"version": "1.1.1",
|
|
4
|
+
"description": "TypeScript client for the event-hub webhook fan-out hub: topics, subscribers, events, deliveries, ingest and timing-safe HMAC delivery-signature verification. Zero runtime dependencies (native fetch + node:crypto).",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"event-hub",
|
|
8
|
+
"webhook",
|
|
9
|
+
"fan-out",
|
|
10
|
+
"events",
|
|
11
|
+
"hmac",
|
|
12
|
+
"sdk"
|
|
13
|
+
],
|
|
14
|
+
"type": "module",
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=18"
|
|
17
|
+
},
|
|
18
|
+
"main": "./dist/cjs/index.js",
|
|
19
|
+
"module": "./dist/esm/index.js",
|
|
20
|
+
"types": "./dist/cjs/index.d.ts",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"import": {
|
|
24
|
+
"types": "./dist/esm/index.d.ts",
|
|
25
|
+
"default": "./dist/esm/index.js"
|
|
26
|
+
},
|
|
27
|
+
"require": {
|
|
28
|
+
"types": "./dist/cjs/index.d.ts",
|
|
29
|
+
"default": "./dist/cjs/index.js"
|
|
30
|
+
},
|
|
31
|
+
"default": "./dist/esm/index.js"
|
|
32
|
+
},
|
|
33
|
+
"./package.json": "./package.json"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"dist",
|
|
37
|
+
"README.md"
|
|
38
|
+
],
|
|
39
|
+
"sideEffects": false,
|
|
40
|
+
"scripts": {
|
|
41
|
+
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
42
|
+
"build": "node scripts/build.mjs",
|
|
43
|
+
"test:unit": "node --test test/signature.test.mjs",
|
|
44
|
+
"test:e2e": "node --test test/e2e.test.mjs",
|
|
45
|
+
"prepack": "npm run build"
|
|
46
|
+
},
|
|
47
|
+
"repository": {
|
|
48
|
+
"type": "git",
|
|
49
|
+
"url": "git+https://github.com/ravileal/event-hub.git",
|
|
50
|
+
"directory": "packages/npm"
|
|
51
|
+
},
|
|
52
|
+
"bugs": {
|
|
53
|
+
"url": "https://github.com/ravileal/event-hub/issues"
|
|
54
|
+
},
|
|
55
|
+
"homepage": "https://github.com/ravileal/event-hub/tree/main/packages/npm",
|
|
56
|
+
"publishConfig": {
|
|
57
|
+
"registry": "https://registry.npmjs.org/",
|
|
58
|
+
"access": "public"
|
|
59
|
+
},
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"@types/node": "^22.10.2",
|
|
62
|
+
"typescript": "^5.7.2"
|
|
63
|
+
}
|
|
64
|
+
}
|