@voyant-travel/realtime 0.1.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 +138 -0
- package/dist/bridge.d.ts +31 -0
- package/dist/bridge.d.ts.map +1 -0
- package/dist/bridge.js +46 -0
- package/dist/capabilities.d.ts +36 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +39 -0
- package/dist/index.d.ts +43 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +49 -0
- package/dist/providers/local.d.ts +24 -0
- package/dist/providers/local.d.ts.map +1 -0
- package/dist/providers/local.js +44 -0
- package/dist/providers/voyant-cloud.d.ts +39 -0
- package/dist/providers/voyant-cloud.d.ts.map +1 -0
- package/dist/providers/voyant-cloud.js +20 -0
- package/dist/routes.d.ts +48 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +65 -0
- package/dist/service.d.ts +21 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +38 -0
- package/dist/types.d.ts +102 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/package.json +89 -0
package/README.md
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# @voyant-travel/realtime
|
|
2
|
+
|
|
3
|
+
Provider-agnostic realtime channels for Voyant deployments. Push live updates to
|
|
4
|
+
admin and customer-facing UIs instead of polling — without coupling the
|
|
5
|
+
framework to any single transport vendor.
|
|
6
|
+
|
|
7
|
+
Implements [RFC #1695](https://github.com/voyant-travel/voyant/issues/1695).
|
|
8
|
+
|
|
9
|
+
## What's in the box
|
|
10
|
+
|
|
11
|
+
- **`RealtimeProvider`** — the transport interface. Voyant Cloud is one
|
|
12
|
+
implementation; any pub/sub backend (Ably, Pusher, Centrifugo, a self-hosted
|
|
13
|
+
WebSocket/SSE service, …) can satisfy it.
|
|
14
|
+
- **EventBus bridge** (`createRealtimeBridge`) — deferred, outbox-safe
|
|
15
|
+
subscribers that fan domain events out to channels as **invalidation hints**.
|
|
16
|
+
- **Token-mint route** (`createRealtimeRoutes`) — issues short-lived,
|
|
17
|
+
capability-scoped client tokens from the caller's session. Browsers never see
|
|
18
|
+
API keys.
|
|
19
|
+
- **`createRealtimeHonoModule`** — wires all of the above into a `HonoModule`.
|
|
20
|
+
|
|
21
|
+
The module owns no schema and is **fully optional**: with no provider
|
|
22
|
+
configured, the token route returns `503` and no subscribers register.
|
|
23
|
+
|
|
24
|
+
## The provider interface (bring your own vendor)
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
interface RealtimeProvider {
|
|
28
|
+
readonly name: string
|
|
29
|
+
publish(channel: string, message: { event: string; data: unknown }): Promise<void>
|
|
30
|
+
mintClientToken(input: {
|
|
31
|
+
clientId: string
|
|
32
|
+
capabilities: Record<string, ReadonlyArray<"subscribe" | "publish" | "presence">>
|
|
33
|
+
ttlSeconds?: number
|
|
34
|
+
}): Promise<{ token: string; expiresAt: string }>
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Built-in implementations:
|
|
39
|
+
|
|
40
|
+
| Factory | Backend | Use |
|
|
41
|
+
| --- | --- | --- |
|
|
42
|
+
| `createLocalRealtimeProvider()` | in-memory | dev, tests, reference impl |
|
|
43
|
+
| `createVoyantCloudRealtimeProvider({ client })` | Voyant Cloud | default cloud transport |
|
|
44
|
+
|
|
45
|
+
### Implementing your own adapter
|
|
46
|
+
|
|
47
|
+
`mintClientToken` is the abstraction line: every vendor mints tokens
|
|
48
|
+
differently (Ably `TokenRequest`, Pusher auth signature, a self-hosted JWT), but
|
|
49
|
+
the token-mint route never knows the vendor.
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
import type { RealtimeProvider } from "@voyant-travel/realtime/types"
|
|
53
|
+
|
|
54
|
+
export function createMyVendorProvider(opts: MyVendorOptions): RealtimeProvider {
|
|
55
|
+
const client = new MyVendorSdk(opts)
|
|
56
|
+
return {
|
|
57
|
+
name: "my-vendor",
|
|
58
|
+
async publish(channel, message) {
|
|
59
|
+
await client.trigger(channel, message.event, message.data)
|
|
60
|
+
},
|
|
61
|
+
async mintClientToken({ clientId, capabilities, ttlSeconds }) {
|
|
62
|
+
const { token, exp } = await client.signToken({ clientId, capabilities, ttlSeconds })
|
|
63
|
+
return { token, expiresAt: new Date(exp * 1000).toISOString() }
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Pass it to `createRealtimeHonoModule({ providers: [createMyVendorProvider(...)] })`
|
|
70
|
+
and the deployment is live on your backend — no framework changes.
|
|
71
|
+
|
|
72
|
+
## Wiring it into an app
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
import { createRealtimeHonoModule } from "@voyant-travel/realtime"
|
|
76
|
+
import { createVoyantCloudRealtimeProvider } from "@voyant-travel/realtime/providers/voyant-cloud"
|
|
77
|
+
|
|
78
|
+
const realtime = createRealtimeHonoModule({
|
|
79
|
+
// Resolve the provider from runtime bindings (e.g. a Cloud client from env).
|
|
80
|
+
resolveProviders: (bindings) => [
|
|
81
|
+
createVoyantCloudRealtimeProvider({ client: getCloudClient(bindings) }),
|
|
82
|
+
],
|
|
83
|
+
|
|
84
|
+
// Fan domain events out to channels. Payload is an invalidation hint.
|
|
85
|
+
bridgeRoutes: {
|
|
86
|
+
"booking.confirmed": (e) => ["admin", `booking:${e.bookingId}`],
|
|
87
|
+
"booking.fully-paid": (e) => ({
|
|
88
|
+
channels: ["admin", `booking:${e.bookingId}`],
|
|
89
|
+
hint: { entity: "booking", id: e.bookingId },
|
|
90
|
+
}),
|
|
91
|
+
"availability.slot.changed": (e) => ({
|
|
92
|
+
channels: ["admin", `product:${e.productId}`],
|
|
93
|
+
hint: { entity: "availability", id: e.productId },
|
|
94
|
+
}),
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
// Let portal customers subscribe to the bookings they own.
|
|
98
|
+
resolvePortalScope: async (c) => {
|
|
99
|
+
const personId = await lookupPersonId(c.get("db"), c.get("userId"))
|
|
100
|
+
if (!personId) return null
|
|
101
|
+
return { personId, bookingIds: await ownedBookingIds(c.get("db"), personId) }
|
|
102
|
+
},
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
// Register `realtime` like any other HonoModule. The token route mounts at
|
|
106
|
+
// POST /v1/admin/realtime/token and POST /v1/public/realtime/token.
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Why invalidation hints, not entities
|
|
110
|
+
|
|
111
|
+
The bridge's default payload is `{ event, entity, id }`, not the changed record.
|
|
112
|
+
The React layer reacts by invalidating matching React Query keys and refetching
|
|
113
|
+
over the existing authenticated HTTP path. This keeps HTTP the source of truth,
|
|
114
|
+
makes at-most-once delivery acceptable (a missed hint self-heals on the next
|
|
115
|
+
refetch/`staleTime` tick), and avoids leaking entity data through channel
|
|
116
|
+
capabilities.
|
|
117
|
+
|
|
118
|
+
Bridge subscribers are **deferred** (`inline: false`) — they run after the HTTP
|
|
119
|
+
response via the runtime scheduler (`executionCtx.waitUntil` on Workers) and
|
|
120
|
+
never block the emitting transaction. Publish failures are swallowed (routed to
|
|
121
|
+
`onPublishError`), because a dropped hint is self-healing.
|
|
122
|
+
|
|
123
|
+
> **Stronger guarantees?** If a channel ever needs at-least-once delivery, the
|
|
124
|
+
> codebase's durable channel-push pattern (write an intent row in a deferred
|
|
125
|
+
> subscriber → drain via a workflow) is the upgrade path. This module ships the
|
|
126
|
+
> at-most-once tier by design.
|
|
127
|
+
|
|
128
|
+
## Channel conventions
|
|
129
|
+
|
|
130
|
+
| Channel | Audience | Capability granted by |
|
|
131
|
+
| --- | --- | --- |
|
|
132
|
+
| `admin` | all admin users of the deployment | admin session |
|
|
133
|
+
| `booking:{bookingId}` | admins; the booking's customer | session + ownership |
|
|
134
|
+
| `portal:customer:{personId}` | that customer in the portal | portal session |
|
|
135
|
+
| `notifications:user:{userId}` | a specific staff user | admin session |
|
|
136
|
+
|
|
137
|
+
See `@voyant-travel/realtime-react` for the `useChannel` / `usePresence` /
|
|
138
|
+
`useLiveQueries` hooks that consume these channels.
|
package/dist/bridge.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Subscriber } from "@voyant-travel/core";
|
|
2
|
+
import type { RealtimeProvider, RealtimeRoutes } from "./types.js";
|
|
3
|
+
export interface CreateRealtimeBridgeOptions {
|
|
4
|
+
/** Transport to publish through. */
|
|
5
|
+
provider: RealtimeProvider;
|
|
6
|
+
/** Declarative event → channel routing table. */
|
|
7
|
+
routes: RealtimeRoutes;
|
|
8
|
+
/**
|
|
9
|
+
* Optional sink for publish failures. Defaults to `console.warn`. The bridge
|
|
10
|
+
* never throws: a dropped hint self-heals on the next refetch, so a transport
|
|
11
|
+
* blip must not break the emitting transaction.
|
|
12
|
+
*/
|
|
13
|
+
onError?: (error: unknown, context: {
|
|
14
|
+
event: string;
|
|
15
|
+
channel: string;
|
|
16
|
+
}) => void;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Build the deferred {@link Subscriber}s that fan domain events out to realtime
|
|
20
|
+
* channels. The returned array plugs straight into `Plugin.subscribers` or a
|
|
21
|
+
* module's `bootstrap` via `eventBus.subscribe`.
|
|
22
|
+
*
|
|
23
|
+
* Subscribers are **deferred** (`inline: false`) — they run after the HTTP
|
|
24
|
+
* response via the runtime's scheduler (`executionCtx.waitUntil` on Workers),
|
|
25
|
+
* so they never block the emitting transaction. Delivery is best-effort and
|
|
26
|
+
* at-most-once by design; the published payload is an
|
|
27
|
+
* {@link RealtimeInvalidationHint}, so a missed message self-heals on the next
|
|
28
|
+
* client refetch.
|
|
29
|
+
*/
|
|
30
|
+
export declare function createRealtimeBridge(options: CreateRealtimeBridgeOptions): Subscriber[];
|
|
31
|
+
//# sourceMappingURL=bridge.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bridge.d.ts","sourceRoot":"","sources":["../src/bridge.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAA;AAErD,OAAO,KAAK,EAEV,gBAAgB,EAGhB,cAAc,EACf,MAAM,YAAY,CAAA;AAEnB,MAAM,WAAW,2BAA2B;IAC1C,oCAAoC;IACpC,QAAQ,EAAE,gBAAgB,CAAA;IAC1B,iDAAiD;IACjD,MAAM,EAAE,cAAc,CAAA;IACtB;;;;OAIG;IACH,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAA;CAChF;AAcD;;;;;;;;;;;GAWG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,2BAA2B,GAAG,UAAU,EAAE,CAkCvF"}
|
package/dist/bridge.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
function isRouteResult(value) {
|
|
2
|
+
return !Array.isArray(value);
|
|
3
|
+
}
|
|
4
|
+
/** Derive the entity family from an event name (`booking.confirmed` → `booking`). */
|
|
5
|
+
function resourceOf(event) {
|
|
6
|
+
const dot = event.indexOf(".");
|
|
7
|
+
return dot === -1 ? event : event.slice(0, dot);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Build the deferred {@link Subscriber}s that fan domain events out to realtime
|
|
11
|
+
* channels. The returned array plugs straight into `Plugin.subscribers` or a
|
|
12
|
+
* module's `bootstrap` via `eventBus.subscribe`.
|
|
13
|
+
*
|
|
14
|
+
* Subscribers are **deferred** (`inline: false`) — they run after the HTTP
|
|
15
|
+
* response via the runtime's scheduler (`executionCtx.waitUntil` on Workers),
|
|
16
|
+
* so they never block the emitting transaction. Delivery is best-effort and
|
|
17
|
+
* at-most-once by design; the published payload is an
|
|
18
|
+
* {@link RealtimeInvalidationHint}, so a missed message self-heals on the next
|
|
19
|
+
* client refetch.
|
|
20
|
+
*/
|
|
21
|
+
export function createRealtimeBridge(options) {
|
|
22
|
+
const { provider, routes } = options;
|
|
23
|
+
const onError = options.onError ??
|
|
24
|
+
((error, context) => {
|
|
25
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
26
|
+
console.warn(`[realtime] publish failed for ${context.event} → ${context.channel}: ${message}`);
|
|
27
|
+
});
|
|
28
|
+
return Object.entries(routes).map(([event, route]) => ({
|
|
29
|
+
event,
|
|
30
|
+
inline: false,
|
|
31
|
+
handler: async (envelope) => {
|
|
32
|
+
const result = route(envelope.data, envelope);
|
|
33
|
+
const channels = isRouteResult(result) ? result.channels : result;
|
|
34
|
+
if (channels.length === 0)
|
|
35
|
+
return;
|
|
36
|
+
const hint = {
|
|
37
|
+
event,
|
|
38
|
+
entity: resourceOf(event),
|
|
39
|
+
...(isRouteResult(result) ? result.hint : undefined),
|
|
40
|
+
};
|
|
41
|
+
await Promise.all(channels.map((channel) => provider
|
|
42
|
+
.publish(channel, { event, data: hint })
|
|
43
|
+
.catch((error) => onError(error, { event, channel }))));
|
|
44
|
+
},
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Actor } from "@voyant-travel/core";
|
|
2
|
+
import type { RealtimeCapabilities } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Portal ownership scope for a customer session — supplied by the deployment
|
|
5
|
+
* (which owns the CRM/bookings schemas), so this package stays decoupled from
|
|
6
|
+
* those modules.
|
|
7
|
+
*/
|
|
8
|
+
export interface PortalScope {
|
|
9
|
+
/** The CRM person id behind the portal session. */
|
|
10
|
+
personId: string;
|
|
11
|
+
/** Booking ids the customer may subscribe to. */
|
|
12
|
+
bookingIds: ReadonlyArray<string>;
|
|
13
|
+
}
|
|
14
|
+
export interface RealtimeCapabilityContext {
|
|
15
|
+
actor: Actor | undefined;
|
|
16
|
+
userId: string | undefined;
|
|
17
|
+
/** Resolved portal scope for customer/partner/supplier sessions. */
|
|
18
|
+
portalScope?: PortalScope | null;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Resolve the channel capabilities a session is entitled to, per the RFC's
|
|
22
|
+
* channel conventions:
|
|
23
|
+
*
|
|
24
|
+
* | Channel | Audience |
|
|
25
|
+
* | ------------------------------ | ------------------------------------- |
|
|
26
|
+
* | `admin` | all admin users of the deployment |
|
|
27
|
+
* | `booking:{bookingId}` | admins; the booking's customer |
|
|
28
|
+
* | `portal:customer:{personId}` | that customer in the portal |
|
|
29
|
+
* | `notifications:user:{userId}` | a specific staff user |
|
|
30
|
+
*
|
|
31
|
+
* Staff sessions get broad admin scope; portal sessions get only their own
|
|
32
|
+
* person channel and the bookings they own. Browsers never see API keys — only
|
|
33
|
+
* the short-lived scoped token minted from these capabilities.
|
|
34
|
+
*/
|
|
35
|
+
export declare function resolveRealtimeCapabilities(ctx: RealtimeCapabilityContext): RealtimeCapabilities;
|
|
36
|
+
//# sourceMappingURL=capabilities.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"capabilities.d.ts","sourceRoot":"","sources":["../src/capabilities.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,qBAAqB,CAAA;AAEhD,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAA;AAEtD;;;;GAIG;AACH,MAAM,WAAW,WAAW;IAC1B,mDAAmD;IACnD,QAAQ,EAAE,MAAM,CAAA;IAChB,iDAAiD;IACjD,UAAU,EAAE,aAAa,CAAC,MAAM,CAAC,CAAA;CAClC;AAED,MAAM,WAAW,yBAAyB;IACxC,KAAK,EAAE,KAAK,GAAG,SAAS,CAAA;IACxB,MAAM,EAAE,MAAM,GAAG,SAAS,CAAA;IAC1B,oEAAoE;IACpE,WAAW,CAAC,EAAE,WAAW,GAAG,IAAI,CAAA;CACjC;AAKD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,2BAA2B,CAAC,GAAG,EAAE,yBAAyB,GAAG,oBAAoB,CAwBhG"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
const SUBSCRIBE = ["subscribe"];
|
|
2
|
+
const SUBSCRIBE_PRESENCE = ["subscribe", "presence"];
|
|
3
|
+
/**
|
|
4
|
+
* Resolve the channel capabilities a session is entitled to, per the RFC's
|
|
5
|
+
* channel conventions:
|
|
6
|
+
*
|
|
7
|
+
* | Channel | Audience |
|
|
8
|
+
* | ------------------------------ | ------------------------------------- |
|
|
9
|
+
* | `admin` | all admin users of the deployment |
|
|
10
|
+
* | `booking:{bookingId}` | admins; the booking's customer |
|
|
11
|
+
* | `portal:customer:{personId}` | that customer in the portal |
|
|
12
|
+
* | `notifications:user:{userId}` | a specific staff user |
|
|
13
|
+
*
|
|
14
|
+
* Staff sessions get broad admin scope; portal sessions get only their own
|
|
15
|
+
* person channel and the bookings they own. Browsers never see API keys — only
|
|
16
|
+
* the short-lived scoped token minted from these capabilities.
|
|
17
|
+
*/
|
|
18
|
+
export function resolveRealtimeCapabilities(ctx) {
|
|
19
|
+
const capabilities = {};
|
|
20
|
+
if (!ctx.userId || !ctx.actor) {
|
|
21
|
+
return capabilities;
|
|
22
|
+
}
|
|
23
|
+
if (ctx.actor === "staff") {
|
|
24
|
+
capabilities.admin = SUBSCRIBE;
|
|
25
|
+
// Wildcard subscribe — admins may watch any booking detail screen.
|
|
26
|
+
capabilities["booking:*"] = SUBSCRIBE;
|
|
27
|
+
capabilities[`notifications:user:${ctx.userId}`] = SUBSCRIBE_PRESENCE;
|
|
28
|
+
return capabilities;
|
|
29
|
+
}
|
|
30
|
+
// Customer / partner / supplier (portal) sessions.
|
|
31
|
+
capabilities[`notifications:user:${ctx.userId}`] = SUBSCRIBE;
|
|
32
|
+
if (ctx.portalScope) {
|
|
33
|
+
capabilities[`portal:customer:${ctx.portalScope.personId}`] = SUBSCRIBE_PRESENCE;
|
|
34
|
+
for (const bookingId of ctx.portalScope.bookingIds) {
|
|
35
|
+
capabilities[`booking:${bookingId}`] = SUBSCRIBE;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return capabilities;
|
|
39
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Module } from "@voyant-travel/core";
|
|
2
|
+
import type { HonoModule } from "@voyant-travel/hono/module";
|
|
3
|
+
import { type RealtimeRoutesOptions } from "./routes.js";
|
|
4
|
+
import type { RealtimeRoutes } from "./types.js";
|
|
5
|
+
export type { CreateRealtimeBridgeOptions } from "./bridge.js";
|
|
6
|
+
export { createRealtimeBridge } from "./bridge.js";
|
|
7
|
+
export type { PortalScope, RealtimeCapabilityContext, } from "./capabilities.js";
|
|
8
|
+
export { resolveRealtimeCapabilities } from "./capabilities.js";
|
|
9
|
+
export type { LocalRealtimeListener, LocalRealtimeProvider, LocalRealtimeProviderOptions, } from "./providers/local.js";
|
|
10
|
+
export { createLocalRealtimeProvider } from "./providers/local.js";
|
|
11
|
+
export type { RealtimeCloudClient, RealtimeCloudNamespace, VoyantCloudRealtimeProviderOptions, } from "./providers/voyant-cloud.js";
|
|
12
|
+
export { createVoyantCloudRealtimeProvider } from "./providers/voyant-cloud.js";
|
|
13
|
+
export type { RealtimeRouteRuntime, RealtimeRoutesOptions, ResolvePortalScope, } from "./routes.js";
|
|
14
|
+
export { buildRealtimeRouteRuntime, createRealtimeRoutes, REALTIME_ROUTE_RUNTIME_CONTAINER_KEY, } from "./routes.js";
|
|
15
|
+
export type { RealtimeService } from "./service.js";
|
|
16
|
+
export { createRealtimeService, RealtimeError } from "./service.js";
|
|
17
|
+
export type { MintClientTokenInput, MintedClientToken, RealtimeCapabilities, RealtimeCapability, RealtimeInvalidationHint, RealtimeMessage, RealtimeProvider, RealtimeRoute, RealtimeRouteResult, RealtimeRoutes, } from "./types.js";
|
|
18
|
+
/** Core module identity. The realtime module owns no schema — it is stateless. */
|
|
19
|
+
export declare const realtimeModule: Module;
|
|
20
|
+
export interface CreateRealtimeHonoModuleOptions extends RealtimeRoutesOptions {
|
|
21
|
+
/**
|
|
22
|
+
* Declarative event → channel routing table. When set, deferred EventBus
|
|
23
|
+
* subscribers are registered at bootstrap to fan domain events out to
|
|
24
|
+
* realtime channels as invalidation hints.
|
|
25
|
+
*/
|
|
26
|
+
bridgeRoutes?: RealtimeRoutes;
|
|
27
|
+
/** Optional sink for bridge publish failures (defaults to `console.warn`). */
|
|
28
|
+
onPublishError?: (error: unknown, context: {
|
|
29
|
+
event: string;
|
|
30
|
+
channel: string;
|
|
31
|
+
}) => void;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Assemble the realtime {@link HonoModule}: the token-mint route (mounted on
|
|
35
|
+
* both admin and public surfaces) plus, when `bridgeRoutes` is supplied, the
|
|
36
|
+
* deferred EventBus → channel bridge.
|
|
37
|
+
*
|
|
38
|
+
* The module is fully optional: with no providers configured the token route
|
|
39
|
+
* returns 503 and no subscribers are registered, so deployments can adopt
|
|
40
|
+
* realtime incrementally (or never).
|
|
41
|
+
*/
|
|
42
|
+
export declare function createRealtimeHonoModule(options?: CreateRealtimeHonoModuleOptions): HonoModule;
|
|
43
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAA;AACjD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,4BAA4B,CAAA;AAG5D,OAAO,EAIL,KAAK,qBAAqB,EAC3B,MAAM,aAAa,CAAA;AACpB,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAA;AAEhD,YAAY,EAAE,2BAA2B,EAAE,MAAM,aAAa,CAAA;AAC9D,OAAO,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAA;AAClD,YAAY,EACV,WAAW,EACX,yBAAyB,GAC1B,MAAM,mBAAmB,CAAA;AAC1B,OAAO,EAAE,2BAA2B,EAAE,MAAM,mBAAmB,CAAA;AAC/D,YAAY,EACV,qBAAqB,EACrB,qBAAqB,EACrB,4BAA4B,GAC7B,MAAM,sBAAsB,CAAA;AAC7B,OAAO,EAAE,2BAA2B,EAAE,MAAM,sBAAsB,CAAA;AAClE,YAAY,EACV,mBAAmB,EACnB,sBAAsB,EACtB,kCAAkC,GACnC,MAAM,6BAA6B,CAAA;AACpC,OAAO,EAAE,iCAAiC,EAAE,MAAM,6BAA6B,CAAA;AAC/E,YAAY,EACV,oBAAoB,EACpB,qBAAqB,EACrB,kBAAkB,GACnB,MAAM,aAAa,CAAA;AACpB,OAAO,EACL,yBAAyB,EACzB,oBAAoB,EACpB,oCAAoC,GACrC,MAAM,aAAa,CAAA;AACpB,YAAY,EAAE,eAAe,EAAE,MAAM,cAAc,CAAA;AACnD,OAAO,EAAE,qBAAqB,EAAE,aAAa,EAAE,MAAM,cAAc,CAAA;AACnE,YAAY,EACV,oBAAoB,EACpB,iBAAiB,EACjB,oBAAoB,EACpB,kBAAkB,EAClB,wBAAwB,EACxB,eAAe,EACf,gBAAgB,EAChB,aAAa,EACb,mBAAmB,EACnB,cAAc,GACf,MAAM,YAAY,CAAA;AAEnB,kFAAkF;AAClF,eAAO,MAAM,cAAc,EAAE,MAE5B,CAAA;AAED,MAAM,WAAW,+BAAgC,SAAQ,qBAAqB;IAC5E;;;;OAIG;IACH,YAAY,CAAC,EAAE,cAAc,CAAA;IAC7B,8EAA8E;IAC9E,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAA;CACvF;AAED;;;;;;;;GAQG;AACH,wBAAgB,wBAAwB,CACtC,OAAO,GAAE,+BAAoC,GAC5C,UAAU,CA8BZ"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createRealtimeBridge } from "./bridge.js";
|
|
2
|
+
import { buildRealtimeRouteRuntime, createRealtimeRoutes, REALTIME_ROUTE_RUNTIME_CONTAINER_KEY, } from "./routes.js";
|
|
3
|
+
export { createRealtimeBridge } from "./bridge.js";
|
|
4
|
+
export { resolveRealtimeCapabilities } from "./capabilities.js";
|
|
5
|
+
export { createLocalRealtimeProvider } from "./providers/local.js";
|
|
6
|
+
export { createVoyantCloudRealtimeProvider } from "./providers/voyant-cloud.js";
|
|
7
|
+
export { buildRealtimeRouteRuntime, createRealtimeRoutes, REALTIME_ROUTE_RUNTIME_CONTAINER_KEY, } from "./routes.js";
|
|
8
|
+
export { createRealtimeService, RealtimeError } from "./service.js";
|
|
9
|
+
/** Core module identity. The realtime module owns no schema — it is stateless. */
|
|
10
|
+
export const realtimeModule = {
|
|
11
|
+
name: "realtime",
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Assemble the realtime {@link HonoModule}: the token-mint route (mounted on
|
|
15
|
+
* both admin and public surfaces) plus, when `bridgeRoutes` is supplied, the
|
|
16
|
+
* deferred EventBus → channel bridge.
|
|
17
|
+
*
|
|
18
|
+
* The module is fully optional: with no providers configured the token route
|
|
19
|
+
* returns 503 and no subscribers are registered, so deployments can adopt
|
|
20
|
+
* realtime incrementally (or never).
|
|
21
|
+
*/
|
|
22
|
+
export function createRealtimeHonoModule(options = {}) {
|
|
23
|
+
const routes = createRealtimeRoutes(options);
|
|
24
|
+
const module = {
|
|
25
|
+
...realtimeModule,
|
|
26
|
+
bootstrap: ({ bindings, container, eventBus }) => {
|
|
27
|
+
const runtime = buildRealtimeRouteRuntime(bindings, options);
|
|
28
|
+
container.register(REALTIME_ROUTE_RUNTIME_CONTAINER_KEY, runtime);
|
|
29
|
+
// No provider configured → nothing to publish to; stay inert.
|
|
30
|
+
if (runtime.service && options.bridgeRoutes && Object.keys(options.bridgeRoutes).length > 0) {
|
|
31
|
+
const subscribers = createRealtimeBridge({
|
|
32
|
+
provider: runtime.service.defaultProvider,
|
|
33
|
+
routes: options.bridgeRoutes,
|
|
34
|
+
onError: options.onPublishError,
|
|
35
|
+
});
|
|
36
|
+
for (const subscriber of subscribers) {
|
|
37
|
+
eventBus.subscribe(subscriber.event, subscriber.handler, {
|
|
38
|
+
inline: subscriber.inline ?? false,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
return {
|
|
45
|
+
module,
|
|
46
|
+
adminRoutes: routes,
|
|
47
|
+
publicRoutes: routes,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { RealtimeMessage, RealtimeProvider } from "../types.js";
|
|
2
|
+
export type LocalRealtimeListener = (message: RealtimeMessage) => void;
|
|
3
|
+
export interface LocalRealtimeProviderOptions {
|
|
4
|
+
/** Provider name (defaults to `"local"`). */
|
|
5
|
+
name?: string;
|
|
6
|
+
/**
|
|
7
|
+
* Optional sink for every published message (across all channels). Defaults
|
|
8
|
+
* to `console.log`. Tests can pass a `vi.fn()` to capture publishes.
|
|
9
|
+
*/
|
|
10
|
+
sink?: (channel: string, message: RealtimeMessage) => void;
|
|
11
|
+
/** Default token lifetime in seconds (defaults to 3600). */
|
|
12
|
+
defaultTtlSeconds?: number;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* In-memory realtime provider for development, tests, and as the reference
|
|
16
|
+
* implementation. Supports per-channel subscription so an in-process consumer
|
|
17
|
+
* (or a test) can observe fan-out without a network transport.
|
|
18
|
+
*/
|
|
19
|
+
export interface LocalRealtimeProvider extends RealtimeProvider {
|
|
20
|
+
/** Subscribe an in-process listener to a channel. Returns an unsubscribe fn. */
|
|
21
|
+
subscribe(channel: string, listener: LocalRealtimeListener): () => void;
|
|
22
|
+
}
|
|
23
|
+
export declare function createLocalRealtimeProvider(options?: LocalRealtimeProviderOptions): LocalRealtimeProvider;
|
|
24
|
+
//# sourceMappingURL=local.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"local.d.ts","sourceRoot":"","sources":["../../src/providers/local.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAGV,eAAe,EACf,gBAAgB,EACjB,MAAM,aAAa,CAAA;AAEpB,MAAM,MAAM,qBAAqB,GAAG,CAAC,OAAO,EAAE,eAAe,KAAK,IAAI,CAAA;AAEtE,MAAM,WAAW,4BAA4B;IAC3C,6CAA6C;IAC7C,IAAI,CAAC,EAAE,MAAM,CAAA;IACb;;;OAGG;IACH,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,eAAe,KAAK,IAAI,CAAA;IAC1D,4DAA4D;IAC5D,iBAAiB,CAAC,EAAE,MAAM,CAAA;CAC3B;AAED;;;;GAIG;AACH,MAAM,WAAW,qBAAsB,SAAQ,gBAAgB;IAC7D,gFAAgF;IAChF,SAAS,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,qBAAqB,GAAG,MAAM,IAAI,CAAA;CACxE;AAED,wBAAgB,2BAA2B,CACzC,OAAO,GAAE,4BAAiC,GACzC,qBAAqB,CA8CvB"}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export function createLocalRealtimeProvider(options = {}) {
|
|
2
|
+
const name = options.name ?? "local";
|
|
3
|
+
const defaultTtlSeconds = options.defaultTtlSeconds ?? 3600;
|
|
4
|
+
const sink = options.sink ??
|
|
5
|
+
((channel, message) => {
|
|
6
|
+
console.log(`[realtime:${name}] ${channel}`, message);
|
|
7
|
+
});
|
|
8
|
+
const listeners = new Map();
|
|
9
|
+
let tokenCounter = 0;
|
|
10
|
+
return {
|
|
11
|
+
name,
|
|
12
|
+
async publish(channel, message) {
|
|
13
|
+
sink(channel, message);
|
|
14
|
+
const channelListeners = listeners.get(channel);
|
|
15
|
+
if (channelListeners) {
|
|
16
|
+
for (const listener of channelListeners) {
|
|
17
|
+
listener(message);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
async mintClientToken(input) {
|
|
22
|
+
tokenCounter += 1;
|
|
23
|
+
const ttl = input.ttlSeconds ?? defaultTtlSeconds;
|
|
24
|
+
// Opaque, non-cryptographic token — local provider never authenticates a
|
|
25
|
+
// real transport. The capability set is embedded for inspectability.
|
|
26
|
+
const token = `${name}_${tokenCounter}.${input.clientId}`;
|
|
27
|
+
return {
|
|
28
|
+
token,
|
|
29
|
+
expiresAt: new Date(Date.now() + ttl * 1000).toISOString(),
|
|
30
|
+
};
|
|
31
|
+
},
|
|
32
|
+
subscribe(channel, listener) {
|
|
33
|
+
let set = listeners.get(channel);
|
|
34
|
+
if (!set) {
|
|
35
|
+
set = new Set();
|
|
36
|
+
listeners.set(channel, set);
|
|
37
|
+
}
|
|
38
|
+
set.add(listener);
|
|
39
|
+
return () => {
|
|
40
|
+
set?.delete(listener);
|
|
41
|
+
};
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { MintClientTokenInput, MintedClientToken, RealtimeProvider } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* The subset of the Cloud SDK `realtime` namespace this provider uses —
|
|
4
|
+
* `publish(channel, { event, data })` and `tokens.mint(input)`. Declared
|
|
5
|
+
* structurally (rather than importing `VoyantCloudClient`) to keep this package
|
|
6
|
+
* free of a hard `@voyant-travel/cloud-sdk` dependency; the real
|
|
7
|
+
* `getVoyantCloudClient(env)` satisfies it directly, so deployments pass the SDK
|
|
8
|
+
* client with no cast.
|
|
9
|
+
*
|
|
10
|
+
* @see https://github.com/voyant-travel/voyant/issues/1695
|
|
11
|
+
*/
|
|
12
|
+
export interface RealtimeCloudNamespace {
|
|
13
|
+
publish(channel: string, input: {
|
|
14
|
+
event: string;
|
|
15
|
+
data?: unknown;
|
|
16
|
+
}): Promise<unknown>;
|
|
17
|
+
tokens: {
|
|
18
|
+
mint(input: MintClientTokenInput): Promise<MintedClientToken>;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export interface RealtimeCloudClient {
|
|
22
|
+
realtime: RealtimeCloudNamespace;
|
|
23
|
+
}
|
|
24
|
+
export interface VoyantCloudRealtimeProviderOptions {
|
|
25
|
+
/**
|
|
26
|
+
* Cloud SDK client. Construct via `getVoyantCloudClient(env)` and pass it
|
|
27
|
+
* here; this provider only touches its `realtime` namespace.
|
|
28
|
+
*/
|
|
29
|
+
client: RealtimeCloudClient;
|
|
30
|
+
/** Provider name override (defaults to `"voyant-cloud"`). */
|
|
31
|
+
name?: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Realtime provider backed by Voyant Cloud. All transport, auth, and error
|
|
35
|
+
* handling come from the SDK — no hand-rolled fetch — exactly like
|
|
36
|
+
* `createVoyantCloudEmailProvider`.
|
|
37
|
+
*/
|
|
38
|
+
export declare function createVoyantCloudRealtimeProvider(options: VoyantCloudRealtimeProviderOptions): RealtimeProvider;
|
|
39
|
+
//# sourceMappingURL=voyant-cloud.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"voyant-cloud.d.ts","sourceRoot":"","sources":["../../src/providers/voyant-cloud.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,oBAAoB,EACpB,iBAAiB,EAEjB,gBAAgB,EACjB,MAAM,aAAa,CAAA;AAEpB;;;;;;;;;GASG;AACH,MAAM,WAAW,sBAAsB;IACrC,OAAO,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;IACpF,MAAM,EAAE;QACN,IAAI,CAAC,KAAK,EAAE,oBAAoB,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAAA;KAC9D,CAAA;CACF;AAED,MAAM,WAAW,mBAAmB;IAClC,QAAQ,EAAE,sBAAsB,CAAA;CACjC;AAED,MAAM,WAAW,kCAAkC;IACjD;;;OAGG;IACH,MAAM,EAAE,mBAAmB,CAAA;IAC3B,6DAA6D;IAC7D,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AAED;;;;GAIG;AACH,wBAAgB,iCAAiC,CAC/C,OAAO,EAAE,kCAAkC,GAC1C,gBAAgB,CAclB"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Realtime provider backed by Voyant Cloud. All transport, auth, and error
|
|
3
|
+
* handling come from the SDK — no hand-rolled fetch — exactly like
|
|
4
|
+
* `createVoyantCloudEmailProvider`.
|
|
5
|
+
*/
|
|
6
|
+
export function createVoyantCloudRealtimeProvider(options) {
|
|
7
|
+
const name = options.name ?? "voyant-cloud";
|
|
8
|
+
return {
|
|
9
|
+
name,
|
|
10
|
+
async publish(channel, message) {
|
|
11
|
+
await options.client.realtime.publish(channel, {
|
|
12
|
+
event: message.event,
|
|
13
|
+
data: message.data,
|
|
14
|
+
});
|
|
15
|
+
},
|
|
16
|
+
mintClientToken(input) {
|
|
17
|
+
return options.client.realtime.tokens.mint(input);
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
package/dist/routes.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Actor, ModuleContainer } from "@voyant-travel/core";
|
|
2
|
+
import type { Context } from "hono";
|
|
3
|
+
import { Hono } from "hono";
|
|
4
|
+
import { type PortalScope } from "./capabilities.js";
|
|
5
|
+
import { type RealtimeService } from "./service.js";
|
|
6
|
+
import type { RealtimeProvider } from "./types.js";
|
|
7
|
+
export declare const REALTIME_ROUTE_RUNTIME_CONTAINER_KEY = "providers.realtime.runtime";
|
|
8
|
+
type Env = {
|
|
9
|
+
Bindings: Record<string, unknown>;
|
|
10
|
+
Variables: {
|
|
11
|
+
container: ModuleContainer;
|
|
12
|
+
actor?: Actor;
|
|
13
|
+
userId?: string;
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
/** Resolves the portal ownership scope for a non-staff session. */
|
|
17
|
+
export type ResolvePortalScope = (c: Context<Env>) => Promise<PortalScope | null> | PortalScope | null;
|
|
18
|
+
export interface RealtimeRoutesOptions {
|
|
19
|
+
/** Transport(s) used to mint client tokens. First is the default. */
|
|
20
|
+
providers?: ReadonlyArray<RealtimeProvider>;
|
|
21
|
+
/** Resolve providers from runtime bindings (e.g. a Cloud client from env). */
|
|
22
|
+
resolveProviders?: (bindings: Record<string, unknown>) => ReadonlyArray<RealtimeProvider>;
|
|
23
|
+
/**
|
|
24
|
+
* Resolve the bookings/person a portal session owns. Required for customers
|
|
25
|
+
* to receive `portal:customer:*` / `booking:*` capabilities; without it they
|
|
26
|
+
* only get their personal `notifications:user:*` channel.
|
|
27
|
+
*/
|
|
28
|
+
resolvePortalScope?: ResolvePortalScope;
|
|
29
|
+
/** Default token lifetime in seconds. */
|
|
30
|
+
defaultTtlSeconds?: number;
|
|
31
|
+
}
|
|
32
|
+
export interface RealtimeRouteRuntime {
|
|
33
|
+
/** Null when no provider is configured — the module stays inert/optional. */
|
|
34
|
+
service: RealtimeService | null;
|
|
35
|
+
resolvePortalScope?: ResolvePortalScope;
|
|
36
|
+
defaultTtlSeconds?: number;
|
|
37
|
+
}
|
|
38
|
+
export declare function buildRealtimeRouteRuntime(bindings: Record<string, unknown>, options?: RealtimeRoutesOptions): RealtimeRouteRuntime;
|
|
39
|
+
/**
|
|
40
|
+
* Routes that mint short-lived, capability-scoped client tokens from the
|
|
41
|
+
* caller's session. Mounted as both `adminRoutes` and `publicRoutes`: the app's
|
|
42
|
+
* actor guards ensure `/v1/admin/*` carries a staff actor and `/v1/public/*` a
|
|
43
|
+
* customer/partner/supplier actor, and {@link resolveRealtimeCapabilities}
|
|
44
|
+
* scopes the token accordingly.
|
|
45
|
+
*/
|
|
46
|
+
export declare function createRealtimeRoutes(options?: RealtimeRoutesOptions): Hono<Env>;
|
|
47
|
+
export {};
|
|
48
|
+
//# sourceMappingURL=routes.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAA;AACjE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAA;AACnC,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAA;AAE3B,OAAO,EACL,KAAK,WAAW,EAGjB,MAAM,mBAAmB,CAAA;AAC1B,OAAO,EAAyB,KAAK,eAAe,EAAE,MAAM,cAAc,CAAA;AAC1E,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAA;AAElD,eAAO,MAAM,oCAAoC,+BAA+B,CAAA;AAEhF,KAAK,GAAG,GAAG;IACT,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IACjC,SAAS,EAAE;QACT,SAAS,EAAE,eAAe,CAAA;QAC1B,KAAK,CAAC,EAAE,KAAK,CAAA;QACb,MAAM,CAAC,EAAE,MAAM,CAAA;KAChB,CAAA;CACF,CAAA;AAED,mEAAmE;AACnE,MAAM,MAAM,kBAAkB,GAAG,CAC/B,CAAC,EAAE,OAAO,CAAC,GAAG,CAAC,KACZ,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,GAAG,WAAW,GAAG,IAAI,CAAA;AAErD,MAAM,WAAW,qBAAqB;IACpC,qEAAqE;IACrE,SAAS,CAAC,EAAE,aAAa,CAAC,gBAAgB,CAAC,CAAA;IAC3C,8EAA8E;IAC9E,gBAAgB,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,aAAa,CAAC,gBAAgB,CAAC,CAAA;IACzF;;;;OAIG;IACH,kBAAkB,CAAC,EAAE,kBAAkB,CAAA;IACvC,yCAAyC;IACzC,iBAAiB,CAAC,EAAE,MAAM,CAAA;CAC3B;AAED,MAAM,WAAW,oBAAoB;IACnC,6EAA6E;IAC7E,OAAO,EAAE,eAAe,GAAG,IAAI,CAAA;IAC/B,kBAAkB,CAAC,EAAE,kBAAkB,CAAA;IACvC,iBAAiB,CAAC,EAAE,MAAM,CAAA;CAC3B;AAED,wBAAgB,yBAAyB,CACvC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,OAAO,GAAE,qBAA0B,GAClC,oBAAoB,CAWtB;AAUD;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,GAAE,qBAA0B,GAAG,IAAI,CAAC,GAAG,CAAC,CA0CnF"}
|
package/dist/routes.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { resolveRealtimeCapabilities, } from "./capabilities.js";
|
|
3
|
+
import { createRealtimeService } from "./service.js";
|
|
4
|
+
export const REALTIME_ROUTE_RUNTIME_CONTAINER_KEY = "providers.realtime.runtime";
|
|
5
|
+
export function buildRealtimeRouteRuntime(bindings, options = {}) {
|
|
6
|
+
const providers = options.resolveProviders
|
|
7
|
+
? options.resolveProviders(bindings)
|
|
8
|
+
: (options.providers ?? []);
|
|
9
|
+
return {
|
|
10
|
+
// Tolerate zero providers (e.g. no API key configured): the token route
|
|
11
|
+
// 503s and the bridge registers nothing, rather than failing app boot.
|
|
12
|
+
service: providers.length > 0 ? createRealtimeService(providers) : null,
|
|
13
|
+
resolvePortalScope: options.resolvePortalScope,
|
|
14
|
+
defaultTtlSeconds: options.defaultTtlSeconds,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function getRuntime(c) {
|
|
18
|
+
const container = c.get("container");
|
|
19
|
+
if (!container?.has(REALTIME_ROUTE_RUNTIME_CONTAINER_KEY)) {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
return container.resolve(REALTIME_ROUTE_RUNTIME_CONTAINER_KEY);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Routes that mint short-lived, capability-scoped client tokens from the
|
|
26
|
+
* caller's session. Mounted as both `adminRoutes` and `publicRoutes`: the app's
|
|
27
|
+
* actor guards ensure `/v1/admin/*` carries a staff actor and `/v1/public/*` a
|
|
28
|
+
* customer/partner/supplier actor, and {@link resolveRealtimeCapabilities}
|
|
29
|
+
* scopes the token accordingly.
|
|
30
|
+
*/
|
|
31
|
+
export function createRealtimeRoutes(options = {}) {
|
|
32
|
+
// Eager runtime when providers are passed directly; otherwise rely on the
|
|
33
|
+
// container runtime registered at bootstrap (bindings-derived providers).
|
|
34
|
+
const eagerRuntime = options.providers && !options.resolveProviders
|
|
35
|
+
? buildRealtimeRouteRuntime({}, options)
|
|
36
|
+
: undefined;
|
|
37
|
+
return new Hono().post("/token", async (c) => {
|
|
38
|
+
const runtime = eagerRuntime ?? getRuntime(c);
|
|
39
|
+
if (!runtime?.service) {
|
|
40
|
+
return c.json({ error: "Realtime provider is not configured" }, 503);
|
|
41
|
+
}
|
|
42
|
+
const service = runtime.service;
|
|
43
|
+
const actor = c.get("actor");
|
|
44
|
+
const userId = c.get("userId");
|
|
45
|
+
if (!userId || !actor) {
|
|
46
|
+
return c.json({ error: "Authentication required" }, 401);
|
|
47
|
+
}
|
|
48
|
+
const portalScope = actor !== "staff" && runtime.resolvePortalScope ? await runtime.resolvePortalScope(c) : null;
|
|
49
|
+
const capabilityCtx = { actor, userId, portalScope };
|
|
50
|
+
const capabilities = resolveRealtimeCapabilities(capabilityCtx);
|
|
51
|
+
const minted = await service.mintClientToken({
|
|
52
|
+
clientId: userId,
|
|
53
|
+
capabilities,
|
|
54
|
+
ttlSeconds: runtime.defaultTtlSeconds,
|
|
55
|
+
});
|
|
56
|
+
return c.json({
|
|
57
|
+
data: {
|
|
58
|
+
token: minted.token,
|
|
59
|
+
expiresAt: minted.expiresAt,
|
|
60
|
+
capabilities,
|
|
61
|
+
provider: service.defaultProvider.name,
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { MintClientTokenInput, MintedClientToken, RealtimeMessage, RealtimeProvider } from "./types.js";
|
|
2
|
+
export declare class RealtimeError extends Error {
|
|
3
|
+
constructor(message: string);
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Thin router over one or more {@link RealtimeProvider}s. Most deployments use
|
|
7
|
+
* a single backend, so this mirrors `createNotificationService`: resolve a
|
|
8
|
+
* provider by name, defaulting to the first registered one.
|
|
9
|
+
*/
|
|
10
|
+
export interface RealtimeService {
|
|
11
|
+
/** Publish via the default provider (or the named one when `provider` set). */
|
|
12
|
+
publish(channel: string, message: RealtimeMessage, provider?: string): Promise<void>;
|
|
13
|
+
/** Mint a client token via the default provider (or the named one). */
|
|
14
|
+
mintClientToken(input: MintClientTokenInput, provider?: string): Promise<MintedClientToken>;
|
|
15
|
+
/** Look up a registered provider by name. */
|
|
16
|
+
getProvider(name: string): RealtimeProvider | undefined;
|
|
17
|
+
/** The provider used when no explicit name is given. */
|
|
18
|
+
readonly defaultProvider: RealtimeProvider;
|
|
19
|
+
}
|
|
20
|
+
export declare function createRealtimeService(providers: ReadonlyArray<RealtimeProvider>): RealtimeService;
|
|
21
|
+
//# sourceMappingURL=service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,oBAAoB,EACpB,iBAAiB,EACjB,eAAe,EACf,gBAAgB,EACjB,MAAM,YAAY,CAAA;AAEnB,qBAAa,aAAc,SAAQ,KAAK;gBAC1B,OAAO,EAAE,MAAM;CAI5B;AAED;;;;GAIG;AACH,MAAM,WAAW,eAAe;IAC9B,+EAA+E;IAC/E,OAAO,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,eAAe,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACpF,uEAAuE;IACvE,eAAe,CAAC,KAAK,EAAE,oBAAoB,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAAA;IAC3F,6CAA6C;IAC7C,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,gBAAgB,GAAG,SAAS,CAAA;IACvD,wDAAwD;IACxD,QAAQ,CAAC,eAAe,EAAE,gBAAgB,CAAA;CAC3C;AAED,wBAAgB,qBAAqB,CAAC,SAAS,EAAE,aAAa,CAAC,gBAAgB,CAAC,GAAG,eAAe,CAiCjG"}
|
package/dist/service.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export class RealtimeError extends Error {
|
|
2
|
+
constructor(message) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = "RealtimeError";
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
export function createRealtimeService(providers) {
|
|
8
|
+
const first = providers[0];
|
|
9
|
+
if (!first) {
|
|
10
|
+
throw new RealtimeError("createRealtimeService requires at least one provider");
|
|
11
|
+
}
|
|
12
|
+
const defaultProvider = first;
|
|
13
|
+
const byName = new Map();
|
|
14
|
+
for (const provider of providers) {
|
|
15
|
+
byName.set(provider.name, provider);
|
|
16
|
+
}
|
|
17
|
+
function resolve(name) {
|
|
18
|
+
if (!name)
|
|
19
|
+
return defaultProvider;
|
|
20
|
+
const provider = byName.get(name);
|
|
21
|
+
if (!provider) {
|
|
22
|
+
throw new RealtimeError(`No realtime provider registered with name "${name}"`);
|
|
23
|
+
}
|
|
24
|
+
return provider;
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
defaultProvider,
|
|
28
|
+
async publish(channel, message, provider) {
|
|
29
|
+
await resolve(provider).publish(channel, message);
|
|
30
|
+
},
|
|
31
|
+
async mintClientToken(input, provider) {
|
|
32
|
+
return resolve(provider).mintClientToken(input);
|
|
33
|
+
},
|
|
34
|
+
getProvider(name) {
|
|
35
|
+
return byName.get(name);
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { EventEnvelope } from "@voyant-travel/core";
|
|
2
|
+
/**
|
|
3
|
+
* A realtime capability granted on a channel. Mirrors the capability
|
|
4
|
+
* vocabulary the transport service understands.
|
|
5
|
+
*/
|
|
6
|
+
export type RealtimeCapability = "subscribe" | "publish" | "presence";
|
|
7
|
+
/**
|
|
8
|
+
* Map of channel name (or pattern such as `booking:*`) to the capabilities
|
|
9
|
+
* the holder is granted on it.
|
|
10
|
+
*/
|
|
11
|
+
export type RealtimeCapabilities = Record<string, ReadonlyArray<RealtimeCapability>>;
|
|
12
|
+
/**
|
|
13
|
+
* A message published to a channel. `event` follows the framework's
|
|
14
|
+
* `<resource>.<pastTenseAction>` convention; `data` is the payload subscribers
|
|
15
|
+
* receive. For the EventBus bridge the payload is an
|
|
16
|
+
* {@link RealtimeInvalidationHint}, never the entity itself.
|
|
17
|
+
*/
|
|
18
|
+
export interface RealtimeMessage {
|
|
19
|
+
event: string;
|
|
20
|
+
data: unknown;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Input for minting a short-lived client token a browser uses to connect to
|
|
24
|
+
* the transport. The token encodes the {@link RealtimeCapabilities} so the
|
|
25
|
+
* client can only subscribe to channels it is entitled to.
|
|
26
|
+
*/
|
|
27
|
+
export interface MintClientTokenInput {
|
|
28
|
+
/** Stable identifier for the connecting client (typically the user id). */
|
|
29
|
+
clientId: string;
|
|
30
|
+
/** Channels → capabilities the token grants. */
|
|
31
|
+
capabilities: RealtimeCapabilities;
|
|
32
|
+
/** Optional token lifetime; the provider applies its own default/cap. */
|
|
33
|
+
ttlSeconds?: number;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* A minted client token plus its expiry, returned to the browser.
|
|
37
|
+
*/
|
|
38
|
+
export interface MintedClientToken {
|
|
39
|
+
token: string;
|
|
40
|
+
/** ISO-8601 timestamp at which the token stops being valid. */
|
|
41
|
+
expiresAt: string;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* A pluggable realtime transport. Voyant Cloud is one implementation; any
|
|
45
|
+
* pub/sub backend (Ably, Pusher, Centrifugo, a self-hosted WebSocket/SSE
|
|
46
|
+
* service, …) can satisfy this interface.
|
|
47
|
+
*
|
|
48
|
+
* Built-in implementations:
|
|
49
|
+
* - `createLocalRealtimeProvider` — in-memory pub/sub for dev/tests and the
|
|
50
|
+
* reference implementation.
|
|
51
|
+
* - `createVoyantCloudRealtimeProvider` — delegates to the Cloud SDK's
|
|
52
|
+
* `realtime` namespace.
|
|
53
|
+
*
|
|
54
|
+
* Self-hosters who want a different backend implement this interface in their
|
|
55
|
+
* deployment and pass it to {@link createRealtimeBridge} and the token route.
|
|
56
|
+
*/
|
|
57
|
+
export interface RealtimeProvider {
|
|
58
|
+
/** Unique provider name (e.g. "voyant-cloud", "local", "ably"). */
|
|
59
|
+
readonly name: string;
|
|
60
|
+
/** Fan a message out to subscribers of `channel`. */
|
|
61
|
+
publish(channel: string, message: RealtimeMessage): Promise<void>;
|
|
62
|
+
/** Mint a short-lived, capability-scoped client token. */
|
|
63
|
+
mintClientToken(input: MintClientTokenInput): Promise<MintedClientToken>;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Default channel-message payload: an invalidation *hint*, not the entity.
|
|
67
|
+
*
|
|
68
|
+
* The React layer reacts by invalidating matching React Query keys and
|
|
69
|
+
* refetching over the existing authenticated HTTP path. This keeps HTTP the
|
|
70
|
+
* source of truth, makes at-most-once delivery acceptable (a missed hint
|
|
71
|
+
* self-heals on the next refetch/staleTime tick), and avoids leaking entity
|
|
72
|
+
* data through channel capabilities.
|
|
73
|
+
*/
|
|
74
|
+
export interface RealtimeInvalidationHint {
|
|
75
|
+
/** Originating domain event, e.g. `booking.confirmed`. */
|
|
76
|
+
event: string;
|
|
77
|
+
/** Entity family the hint concerns, e.g. `booking`. */
|
|
78
|
+
entity: string;
|
|
79
|
+
/** Optional id of the affected entity. */
|
|
80
|
+
id?: string;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Result a route function may return to customise the published message. The
|
|
84
|
+
* terse form — returning a `string[]` of channels — is also supported, in
|
|
85
|
+
* which case the hint defaults to `{ event, entity: <resource>, id: undefined }`.
|
|
86
|
+
*/
|
|
87
|
+
export interface RealtimeRouteResult {
|
|
88
|
+
channels: ReadonlyArray<string>;
|
|
89
|
+
/** Overrides merged onto the default {@link RealtimeInvalidationHint}. */
|
|
90
|
+
hint?: Partial<RealtimeInvalidationHint>;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Maps a domain event to the channels it should fan out to. Receives the
|
|
94
|
+
* event payload and the full envelope.
|
|
95
|
+
*/
|
|
96
|
+
export type RealtimeRoute<TData = unknown> = (data: TData, envelope: EventEnvelope<TData>) => ReadonlyArray<string> | RealtimeRouteResult;
|
|
97
|
+
/**
|
|
98
|
+
* Declarative event → channel routing table for {@link createRealtimeBridge}.
|
|
99
|
+
* Keyed by domain event name.
|
|
100
|
+
*/
|
|
101
|
+
export type RealtimeRoutes = Record<string, RealtimeRoute>;
|
|
102
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAA;AAExD;;;GAGG;AACH,MAAM,MAAM,kBAAkB,GAAG,WAAW,GAAG,SAAS,GAAG,UAAU,CAAA;AAErE;;;GAGG;AACH,MAAM,MAAM,oBAAoB,GAAG,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,kBAAkB,CAAC,CAAC,CAAA;AAEpF;;;;;GAKG;AACH,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,OAAO,CAAA;CACd;AAED;;;;GAIG;AACH,MAAM,WAAW,oBAAoB;IACnC,2EAA2E;IAC3E,QAAQ,EAAE,MAAM,CAAA;IAChB,gDAAgD;IAChD,YAAY,EAAE,oBAAoB,CAAA;IAClC,yEAAyE;IACzE,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AAED;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAA;IACb,+DAA+D;IAC/D,SAAS,EAAE,MAAM,CAAA;CAClB;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,WAAW,gBAAgB;IAC/B,mEAAmE;IACnE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,qDAAqD;IACrD,OAAO,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACjE,0DAA0D;IAC1D,eAAe,CAAC,KAAK,EAAE,oBAAoB,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAAA;CACzE;AAED;;;;;;;;GAQG;AACH,MAAM,WAAW,wBAAwB;IACvC,0DAA0D;IAC1D,KAAK,EAAE,MAAM,CAAA;IACb,uDAAuD;IACvD,MAAM,EAAE,MAAM,CAAA;IACd,0CAA0C;IAC1C,EAAE,CAAC,EAAE,MAAM,CAAA;CACZ;AAED;;;;GAIG;AACH,MAAM,WAAW,mBAAmB;IAClC,QAAQ,EAAE,aAAa,CAAC,MAAM,CAAC,CAAA;IAC/B,0EAA0E;IAC1E,IAAI,CAAC,EAAE,OAAO,CAAC,wBAAwB,CAAC,CAAA;CACzC;AAED;;;GAGG;AACH,MAAM,MAAM,aAAa,CAAC,KAAK,GAAG,OAAO,IAAI,CAC3C,IAAI,EAAE,KAAK,EACX,QAAQ,EAAE,aAAa,CAAC,KAAK,CAAC,KAC3B,aAAa,CAAC,MAAM,CAAC,GAAG,mBAAmB,CAAA;AAEhD;;;GAGG;AACH,MAAM,MAAM,cAAc,GAAG,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,CAAA"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@voyant-travel/realtime",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "Apache-2.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts",
|
|
8
|
+
"./types": "./src/types.ts",
|
|
9
|
+
"./service": "./src/service.ts",
|
|
10
|
+
"./bridge": "./src/bridge.ts",
|
|
11
|
+
"./capabilities": "./src/capabilities.ts",
|
|
12
|
+
"./routes": "./src/routes.ts",
|
|
13
|
+
"./providers/local": "./src/providers/local.ts",
|
|
14
|
+
"./providers/voyant-cloud": "./src/providers/voyant-cloud.ts"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"typecheck": "tsc --noEmit",
|
|
18
|
+
"lint": "biome check src/",
|
|
19
|
+
"test": "vitest run",
|
|
20
|
+
"build": "tsc -p tsconfig.json",
|
|
21
|
+
"clean": "rm -rf dist tsconfig.tsbuildinfo",
|
|
22
|
+
"prepack": "pnpm run build"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@voyant-travel/core": "workspace:^",
|
|
26
|
+
"@voyant-travel/hono": "workspace:^",
|
|
27
|
+
"hono": "^4.12.10"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@voyant-travel/voyant-typescript-config": "workspace:^",
|
|
31
|
+
"typescript": "^6.0.2",
|
|
32
|
+
"vitest": "^4.1.2"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"dist"
|
|
36
|
+
],
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public",
|
|
39
|
+
"exports": {
|
|
40
|
+
".": {
|
|
41
|
+
"types": "./dist/index.d.ts",
|
|
42
|
+
"import": "./dist/index.js",
|
|
43
|
+
"default": "./dist/index.js"
|
|
44
|
+
},
|
|
45
|
+
"./types": {
|
|
46
|
+
"types": "./dist/types.d.ts",
|
|
47
|
+
"import": "./dist/types.js",
|
|
48
|
+
"default": "./dist/types.js"
|
|
49
|
+
},
|
|
50
|
+
"./service": {
|
|
51
|
+
"types": "./dist/service.d.ts",
|
|
52
|
+
"import": "./dist/service.js",
|
|
53
|
+
"default": "./dist/service.js"
|
|
54
|
+
},
|
|
55
|
+
"./bridge": {
|
|
56
|
+
"types": "./dist/bridge.d.ts",
|
|
57
|
+
"import": "./dist/bridge.js",
|
|
58
|
+
"default": "./dist/bridge.js"
|
|
59
|
+
},
|
|
60
|
+
"./capabilities": {
|
|
61
|
+
"types": "./dist/capabilities.d.ts",
|
|
62
|
+
"import": "./dist/capabilities.js",
|
|
63
|
+
"default": "./dist/capabilities.js"
|
|
64
|
+
},
|
|
65
|
+
"./routes": {
|
|
66
|
+
"types": "./dist/routes.d.ts",
|
|
67
|
+
"import": "./dist/routes.js",
|
|
68
|
+
"default": "./dist/routes.js"
|
|
69
|
+
},
|
|
70
|
+
"./providers/local": {
|
|
71
|
+
"types": "./dist/providers/local.d.ts",
|
|
72
|
+
"import": "./dist/providers/local.js",
|
|
73
|
+
"default": "./dist/providers/local.js"
|
|
74
|
+
},
|
|
75
|
+
"./providers/voyant-cloud": {
|
|
76
|
+
"types": "./dist/providers/voyant-cloud.d.ts",
|
|
77
|
+
"import": "./dist/providers/voyant-cloud.js",
|
|
78
|
+
"default": "./dist/providers/voyant-cloud.js"
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
"main": "./dist/index.js",
|
|
82
|
+
"types": "./dist/index.d.ts"
|
|
83
|
+
},
|
|
84
|
+
"repository": {
|
|
85
|
+
"type": "git",
|
|
86
|
+
"url": "https://github.com/voyant-travel/voyant.git",
|
|
87
|
+
"directory": "packages/realtime"
|
|
88
|
+
}
|
|
89
|
+
}
|