event-bridge-client
v1.2.0
Published
Lightweight client for registering apps, batching lifecycle events, and handling HMAC-signed remote commands.
Readme
event-bridge-client
A lightweight client for connecting an application to a control backend: register the app, batch and push lifecycle events, and handle HMAC-signed remote commands.
Install
npm install event-bridge-client(Or pnpm add / yarn add.)
Quickstart (Hono)
import { createManagementClient } from "event-bridge-client";
const client = createManagementClient({
baseUrl: process.env.BRIDGE_BASE_URL!,
apiKey: process.env.BRIDGE_API_KEY!,
callbackUrl: "https://api.example.com/bridge/commands",
callbackSecret: process.env.BRIDGE_CALLBACK_SECRET!,
capabilities: ["user.ban", "user.unban"],
});
client.onCommand("user.ban", async ({ externalUserId, reason }) =>
({ ok: true, result: await banAccount(externalUserId, reason) }),
);
app.route("/bridge/commands", client.middleware.hono());
await client.register();
// Anywhere in your app:
client.events.emit("user.created", {
externalUserId: "usr_123",
email: "[email protected]",
});Payments
Take money through the backend's payment module. You create a payment (which gives you a hosted checkout URL to send the payer to) and receive a webhook when it settles — both through the same client, authenticated with the same key.
Do not emit payment.* as events; inbound payment events are rejected. The
payments API and the onPayment webhook are the only payment path.
// 1. Create a payment → redirect/send the payer to the checkout URL.
const { payment, hostedCheckoutUrl } = await client.payments.create({
externalId: "order_4821", // your idempotency key
currency: "UZS",
lineItems: [{ name: "Pro plan — 1 month", unitAmount: 120_000 }],
customer: { email: "[email protected]", externalId: "usr_123" },
});
// hostedCheckoutUrl → e.g. https://pay.example.com/payment/<id>
// 2. Receive the outcome. The same callback middleware that handles commands
// verifies the signature and dispatches here. Make it idempotent —
// delivery is at-least-once.
client.onPayment(async (n) => {
if (n.type === "payment.settled") await markOrderPaid(n.data.externalId);
if (n.type === "payment.failed") await markOrderFailed(n.data.externalId);
});
// (Optional) Poll instead of / in addition to the webhook:
const latest = await client.payments.getByExternalId("order_4821");Notes:
externalIdis your idempotency key. Re-creating with the same one returns the existing payment (idempotent: true).- Line items take either an ad-hoc
name+unitAmount, or a cataloguevariantId(price resolves server-side from the variant). - Non-base currencies require
rateToUzs. - The webhook POSTs to your
callbackUrlby default. PassnotifyUrloncreate()to send a specific payment's webhook somewhere else.
Managed resources
Beyond commands, an app can declare resources — any entity it wants visible and manageable from the control backend (users, orders, anything). The backend renders a generic list / search / detail view + action buttons straight from the descriptor — no backend-side code changes to add one.
Records are never shipped to the backend; it proxies list / get / action
queries back to your app over the same signed callback channel, on demand.
client.defineResource({
key: "widgetUser",
label: "Widget User",
labelPlural: "Widget Users",
titleField: "email", // the field shown as each record's headline
fields: [
{ key: "id", label: "ID", type: "string", listVisible: false },
{ key: "email", label: "Email", type: "email", filterable: true },
{ key: "plan", label: "Plan", type: "enum", enumValues: ["free", "pro"] },
{ key: "createdAt", label: "Joined", type: "datetime", sortable: true },
{ key: "banned", label: "Banned", type: "boolean" },
],
actions: [
{
capability: "widgetUser.ban",
label: "Ban",
confirm: true,
destructive: true,
fields: [{ name: "reason", label: "Reason", kind: "textarea", required: true }],
},
],
// Answer a paginated, filtered list query.
list: async ({ page, pageSize, q }) => {
const { rows, total } = await myDb.searchWidgetUsers({ page, pageSize, q });
return { records: rows, total };
},
// Optional — fetch one record for the detail view.
get: async (id) => myDb.findWidgetUser(id),
// Optional — run a declared action.
action: async ({ capability, recordId, params }) => {
if (capability === "widgetUser.ban") {
await myDb.banWidgetUser(recordId, String(params.reason));
return { ok: true, message: "User banned" };
}
return { ok: false, error: "unknown action" };
},
});
await client.register(); // descriptors are sent with register()Resource queries ride the same callback middleware — nothing extra to mount.
Each record SHOULD carry an id field; the backend uses it to open the detail
view and to address get / action calls. Field type is one of: string,
number, boolean, date, datetime, enum, currency, badge, email,
url, json. Action capabilities are declared here on the resource — they do
not need to go in the top-level capabilities array (that array is for
command-flow capabilities only).
Options
| Option | Default | Notes |
|-------------------|-------------|-------------------------------------------------------------|
| baseUrl | required | Control backend base URL |
| apiKey | required | API key minted by the backend admin |
| callbackUrl | required | HTTPS URL the backend POSTs commands + payment webhooks to |
| callbackSecret | required | HMAC shared secret minted alongside the API key |
| capabilities | required | Strings matching command types, e.g. user.ban |
| enabled | true | If false, all methods are no-ops (safe staged rollout) |
| batchIntervalMs | 1500 | Event batcher flush interval |
| batchMaxSize | 100 | Force-flush when this many events are queued |
| maxBufferSize | 10000 | Hard cap on buffered events; oldest dropped past it |
| maxRetries | 6 | Exponential backoff retries for event batch POSTs |
| nonceStore | in-memory | Replay-protection store; supply a shared one for multi-instance apps |
Receiving commands (Express)
The inbound signature is computed over the raw request bytes. If a JSON body
parser consumes the stream first, those bytes are lost and every verification
fails. Either mount this middleware before express.json(), or capture the
raw bytes:
app.use(express.json({ verify: (req, _res, buf) => { (req as any).rawBody = buf; } }));
app.post("/bridge/commands", client.middleware.express());The Hono middleware reads the raw body itself, so it needs no special setup.
Replay protection across instances
The default replay cache is in-process — it only protects a single instance.
If you run more than one instance behind a load balancer, supply a shared
nonceStore (e.g. Redis) so a captured command can't be replayed against another
instance inside the 300-second signature window:
const nonceStore = {
has: (nonce: string) => redis.exists(`bridge:nonce:${nonce}`).then((n) => n > 0),
add: (nonce: string, ttlMs: number) =>
redis.set(`bridge:nonce:${nonce}`, "1", "PX", ttlMs).then(() => undefined),
};