@voyant-travel/realtime-react 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 ADDED
@@ -0,0 +1,84 @@
1
+ # @voyant-travel/realtime-react
2
+
3
+ React hooks that make existing admin/portal screens live, consuming the channels
4
+ published by [`@voyant-travel/realtime`](../realtime). Vendor-agnostic — the
5
+ transport is injected as a `RealtimeConnector`.
6
+
7
+ Implements the React half of [RFC #1695](https://github.com/voyant-travel/voyant/issues/1695).
8
+
9
+ ## Hooks
10
+
11
+ - **`useLiveQueries(channels, map)`** — the one most screens need. Subscribes to
12
+ channels and translates each invalidation hint into
13
+ `queryClient.invalidateQueries` calls, so screens go live without rewriting
14
+ their data layer.
15
+ - **`useChannel(channel, { onMessage, onPresence })`** — subscribe to a single
16
+ channel with auto token-mint, reconnect, and `sinceId` resume (vendor
17
+ permitting).
18
+ - **`usePresence(channel, profile)`** — member list ("Ana is viewing this
19
+ booking").
20
+
21
+ ## Setup
22
+
23
+ ```tsx
24
+ import { RealtimeChannel } from "@voyant-travel/cloud-sdk"
25
+ import {
26
+ createRealtimeChannelConnector,
27
+ RealtimeReactProvider,
28
+ } from "@voyant-travel/realtime-react"
29
+
30
+ // Adapt the Voyant Cloud RealtimeChannel into a connector.
31
+ const connector = createRealtimeChannelConnector(RealtimeChannel, { baseUrl: "/api" })
32
+
33
+ <QueryClientProvider client={queryClient}>
34
+ <RealtimeReactProvider connector={connector} tokenEndpoint="/v1/admin/realtime/token">
35
+ <App />
36
+ </RealtimeReactProvider>
37
+ </QueryClientProvider>
38
+ ```
39
+
40
+ `connector` is the injection seam: `createRealtimeChannelConnector` wraps the
41
+ cloud-sdk client, but any vendor (Ably, Pusher, a raw WebSocket/SSE wrapper)
42
+ works by implementing `RealtimeConnector` directly.
43
+
44
+ ## Making the dashboard live
45
+
46
+ Replace the 60s polling fallback with hint-driven invalidation — keep the
47
+ interval as a safety net:
48
+
49
+ ```tsx
50
+ import { useLiveQueries } from "@voyant-travel/realtime-react"
51
+ import { dashboardQueryKeys } from "@voyant-travel/admin/dashboard/query-options"
52
+
53
+ function DashboardLive() {
54
+ useLiveQueries(["admin"], (hint) => {
55
+ switch (hint.entity) {
56
+ case "booking":
57
+ return [dashboardQueryKeys.bookingsAggregates()]
58
+ case "invoice":
59
+ return [dashboardQueryKeys.financeAggregates()]
60
+ default:
61
+ return []
62
+ }
63
+ })
64
+ return null
65
+ }
66
+ ```
67
+
68
+ A missed hint self-heals on the next `staleTime` tick, so at-most-once delivery
69
+ is fine — keep `staleTime: 60_000` as the floor.
70
+
71
+ ## The connector interface
72
+
73
+ ```ts
74
+ interface RealtimeConnector {
75
+ subscribe(options: {
76
+ channel: string
77
+ token: string
78
+ onMessage?: (message: { event: string; data: unknown }) => void
79
+ onPresence?: (members: ReadonlyArray<{ clientId: string; profile?: unknown }>) => void
80
+ sinceId?: string
81
+ profile?: unknown
82
+ }): { unsubscribe(): void }
83
+ }
84
+ ```
@@ -0,0 +1,50 @@
1
+ import type { RealtimeConnector } from "./connector.js";
2
+ /**
3
+ * Structural subset of the cloud-sdk `RealtimeChannel` this adapter depends on.
4
+ * Declared locally so `@voyant-travel/realtime-react` stays decoupled from
5
+ * `@voyant-travel/cloud-sdk` — callers inject the real constructor.
6
+ *
7
+ * @see https://github.com/voyant-travel/cloud-sdk/pull/23
8
+ */
9
+ export interface CloudRealtimeMessage {
10
+ event: string;
11
+ data: unknown;
12
+ }
13
+ export interface CloudRealtimePresenceEvent {
14
+ action: "enter" | "leave" | "update";
15
+ clientId: string;
16
+ data?: unknown;
17
+ }
18
+ export interface RealtimeChannelLike {
19
+ on(event: "message", handler: (message: CloudRealtimeMessage) => void): () => void;
20
+ on(event: "presence", handler: (event: CloudRealtimePresenceEvent) => void): () => void;
21
+ enterPresence(data?: unknown): void;
22
+ close(): void;
23
+ }
24
+ export interface RealtimeChannelCtorOptions {
25
+ channel: string;
26
+ token: string;
27
+ baseUrl?: string;
28
+ sinceId?: string;
29
+ }
30
+ export type RealtimeChannelCtor = new (options: RealtimeChannelCtorOptions) => RealtimeChannelLike;
31
+ export interface CreateRealtimeChannelConnectorOptions {
32
+ /** HTTP(S) API origin forwarded to each channel (converted to ws(s)://). */
33
+ baseUrl?: string;
34
+ }
35
+ /**
36
+ * Adapt the cloud-sdk `RealtimeChannel` constructor into a vendor-agnostic
37
+ * {@link RealtimeConnector} for the hooks. Inject the constructor so this stays
38
+ * decoupled from the SDK:
39
+ *
40
+ * ```ts
41
+ * import { RealtimeChannel } from "@voyant-travel/cloud-sdk"
42
+ * const connector = createRealtimeChannelConnector(RealtimeChannel, { baseUrl })
43
+ * ```
44
+ *
45
+ * Presence is tracked incrementally from `enter`/`update`/`leave` events into a
46
+ * member list; subscribers that need the full set on connect should seed it
47
+ * from `client.realtime.presence.get(channel)`.
48
+ */
49
+ export declare function createRealtimeChannelConnector(RealtimeChannelCtor: RealtimeChannelCtor, options?: CreateRealtimeChannelConnectorOptions): RealtimeConnector;
50
+ //# sourceMappingURL=connector-cloud.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"connector-cloud.d.ts","sourceRoot":"","sources":["../src/connector-cloud.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAkB,iBAAiB,EAAE,MAAM,gBAAgB,CAAA;AAEvE;;;;;;GAMG;AACH,MAAM,WAAW,oBAAoB;IACnC,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,OAAO,CAAA;CACd;AAED,MAAM,WAAW,0BAA0B;IACzC,MAAM,EAAE,OAAO,GAAG,OAAO,GAAG,QAAQ,CAAA;IACpC,QAAQ,EAAE,MAAM,CAAA;IAChB,IAAI,CAAC,EAAE,OAAO,CAAA;CACf;AAED,MAAM,WAAW,mBAAmB;IAClC,EAAE,CAAC,KAAK,EAAE,SAAS,EAAE,OAAO,EAAE,CAAC,OAAO,EAAE,oBAAoB,KAAK,IAAI,GAAG,MAAM,IAAI,CAAA;IAClF,EAAE,CAAC,KAAK,EAAE,UAAU,EAAE,OAAO,EAAE,CAAC,KAAK,EAAE,0BAA0B,KAAK,IAAI,GAAG,MAAM,IAAI,CAAA;IACvF,aAAa,CAAC,IAAI,CAAC,EAAE,OAAO,GAAG,IAAI,CAAA;IACnC,KAAK,IAAI,IAAI,CAAA;CACd;AAED,MAAM,WAAW,0BAA0B;IACzC,OAAO,EAAE,MAAM,CAAA;IACf,KAAK,EAAE,MAAM,CAAA;IACb,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB;AAED,MAAM,MAAM,mBAAmB,GAAG,KAAK,OAAO,EAAE,0BAA0B,KAAK,mBAAmB,CAAA;AAElG,MAAM,WAAW,qCAAqC;IACpD,4EAA4E;IAC5E,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,8BAA8B,CAC5C,mBAAmB,EAAE,mBAAmB,EACxC,OAAO,GAAE,qCAA0C,GAClD,iBAAiB,CA8CnB"}
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Adapt the cloud-sdk `RealtimeChannel` constructor into a vendor-agnostic
3
+ * {@link RealtimeConnector} for the hooks. Inject the constructor so this stays
4
+ * decoupled from the SDK:
5
+ *
6
+ * ```ts
7
+ * import { RealtimeChannel } from "@voyant-travel/cloud-sdk"
8
+ * const connector = createRealtimeChannelConnector(RealtimeChannel, { baseUrl })
9
+ * ```
10
+ *
11
+ * Presence is tracked incrementally from `enter`/`update`/`leave` events into a
12
+ * member list; subscribers that need the full set on connect should seed it
13
+ * from `client.realtime.presence.get(channel)`.
14
+ */
15
+ export function createRealtimeChannelConnector(RealtimeChannelCtor, options = {}) {
16
+ return {
17
+ subscribe({ channel, token, sinceId, profile, onMessage, onPresence }) {
18
+ const channelClient = new RealtimeChannelCtor({
19
+ channel,
20
+ token,
21
+ ...(sinceId !== undefined ? { sinceId } : {}),
22
+ ...(options.baseUrl !== undefined ? { baseUrl: options.baseUrl } : {}),
23
+ });
24
+ const teardown = [];
25
+ if (onMessage) {
26
+ teardown.push(channelClient.on("message", (message) => onMessage({ event: message.event, data: message.data })));
27
+ }
28
+ if (onPresence) {
29
+ const members = new Map();
30
+ teardown.push(channelClient.on("presence", (event) => {
31
+ if (event.action === "leave") {
32
+ members.delete(event.clientId);
33
+ }
34
+ else {
35
+ members.set(event.clientId, { clientId: event.clientId, profile: event.data });
36
+ }
37
+ onPresence([...members.values()]);
38
+ }));
39
+ }
40
+ if (profile !== undefined) {
41
+ channelClient.enterPresence(profile);
42
+ }
43
+ return {
44
+ unsubscribe() {
45
+ for (const off of teardown)
46
+ off();
47
+ channelClient.close();
48
+ },
49
+ };
50
+ },
51
+ };
52
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * A realtime message as seen by the browser. Mirrors the server-side
3
+ * `RealtimeMessage` from `@voyant-travel/realtime`; for the EventBus bridge the
4
+ * `data` is a `RealtimeInvalidationHint`.
5
+ */
6
+ export interface RealtimeClientMessage {
7
+ event: string;
8
+ data: unknown;
9
+ }
10
+ /** A presence member published by the transport. */
11
+ export interface PresenceMember {
12
+ clientId: string;
13
+ profile?: unknown;
14
+ }
15
+ export interface RealtimeSubscribeOptions {
16
+ channel: string;
17
+ /** Short-lived client token minted by the realtime token route. */
18
+ token: string;
19
+ onMessage?: (message: RealtimeClientMessage) => void;
20
+ onPresence?: (members: ReadonlyArray<PresenceMember>) => void;
21
+ /** Resume marker for at-least-once vendors that support replay. */
22
+ sinceId?: string;
23
+ /** Local presence profile announced to the channel's member set. */
24
+ profile?: unknown;
25
+ }
26
+ /** A live subscription handle. */
27
+ export interface RealtimeConnection {
28
+ unsubscribe(): void;
29
+ }
30
+ /**
31
+ * Vendor-specific browser transport. This is the injection seam that keeps the
32
+ * hooks vendor-agnostic: Voyant Cloud ships a connector backed by its
33
+ * `RealtimeChannel` client, and self-hosters pass their own (Ably, Pusher,
34
+ * a raw WebSocket/SSE wrapper, …).
35
+ */
36
+ export interface RealtimeConnector {
37
+ subscribe(options: RealtimeSubscribeOptions): RealtimeConnection;
38
+ }
39
+ //# sourceMappingURL=connector.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"connector.d.ts","sourceRoot":"","sources":["../src/connector.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,WAAW,qBAAqB;IACpC,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,OAAO,CAAA;CACd;AAED,oDAAoD;AACpD,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,MAAM,CAAA;IAChB,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAED,MAAM,WAAW,wBAAwB;IACvC,OAAO,EAAE,MAAM,CAAA;IACf,mEAAmE;IACnE,KAAK,EAAE,MAAM,CAAA;IACb,SAAS,CAAC,EAAE,CAAC,OAAO,EAAE,qBAAqB,KAAK,IAAI,CAAA;IACpD,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,aAAa,CAAC,cAAc,CAAC,KAAK,IAAI,CAAA;IAC7D,mEAAmE;IACnE,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,oEAAoE;IACpE,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAED,kCAAkC;AAClC,MAAM,WAAW,kBAAkB;IACjC,WAAW,IAAI,IAAI,CAAA;CACpB;AAED;;;;;GAKG;AACH,MAAM,WAAW,iBAAiB;IAChC,SAAS,CAAC,OAAO,EAAE,wBAAwB,GAAG,kBAAkB,CAAA;CACjE"}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,13 @@
1
+ export type { PresenceMember, RealtimeClientMessage, RealtimeConnection, RealtimeConnector, RealtimeSubscribeOptions, } from "./connector.js";
2
+ export type { CreateRealtimeChannelConnectorOptions, RealtimeChannelCtor, RealtimeChannelCtorOptions, RealtimeChannelLike, } from "./connector-cloud.js";
3
+ export { createRealtimeChannelConnector } from "./connector-cloud.js";
4
+ export type { RealtimeReactContextValue, RealtimeReactProviderProps, RealtimeTokenFetcher, } from "./provider.js";
5
+ export { RealtimeReactProvider, useRealtimeContext } from "./provider.js";
6
+ export type { HintToQueryKeys, RealtimeInvalidationHint, } from "./query-keys.js";
7
+ export { resolveInvalidationKeys } from "./query-keys.js";
8
+ export type { UseChannelOptions } from "./use-channel.js";
9
+ export { useChannel } from "./use-channel.js";
10
+ export type { UseLiveQueriesOptions } from "./use-live-queries.js";
11
+ export { useLiveQueries } from "./use-live-queries.js";
12
+ export { usePresence } from "./use-presence.js";
13
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EACV,cAAc,EACd,qBAAqB,EACrB,kBAAkB,EAClB,iBAAiB,EACjB,wBAAwB,GACzB,MAAM,gBAAgB,CAAA;AACvB,YAAY,EACV,qCAAqC,EACrC,mBAAmB,EACnB,0BAA0B,EAC1B,mBAAmB,GACpB,MAAM,sBAAsB,CAAA;AAC7B,OAAO,EAAE,8BAA8B,EAAE,MAAM,sBAAsB,CAAA;AACrE,YAAY,EACV,yBAAyB,EACzB,0BAA0B,EAC1B,oBAAoB,GACrB,MAAM,eAAe,CAAA;AACtB,OAAO,EAAE,qBAAqB,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAA;AACzE,YAAY,EACV,eAAe,EACf,wBAAwB,GACzB,MAAM,iBAAiB,CAAA;AACxB,OAAO,EAAE,uBAAuB,EAAE,MAAM,iBAAiB,CAAA;AACzD,YAAY,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAA;AACzD,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAA;AAC7C,YAAY,EAAE,qBAAqB,EAAE,MAAM,uBAAuB,CAAA;AAClE,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAA;AACtD,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export { createRealtimeChannelConnector } from "./connector-cloud.js";
2
+ export { RealtimeReactProvider, useRealtimeContext } from "./provider.js";
3
+ export { resolveInvalidationKeys } from "./query-keys.js";
4
+ export { useChannel } from "./use-channel.js";
5
+ export { useLiveQueries } from "./use-live-queries.js";
6
+ export { usePresence } from "./use-presence.js";
@@ -0,0 +1,28 @@
1
+ import { type ReactNode } from "react";
2
+ import type { RealtimeConnector } from "./connector.js";
3
+ export type RealtimeTokenFetcher = () => Promise<{
4
+ token: string;
5
+ expiresAt: string;
6
+ }>;
7
+ export interface RealtimeReactContextValue {
8
+ connector: RealtimeConnector;
9
+ /** Fetches a fresh client token from the deployment's token-mint route. */
10
+ fetchToken: RealtimeTokenFetcher;
11
+ }
12
+ export interface RealtimeReactProviderProps {
13
+ /** Vendor-specific browser transport (Voyant Cloud, Ably, Pusher, …). */
14
+ connector: RealtimeConnector;
15
+ /**
16
+ * Token endpoint to call for a scoped client token. Defaults to
17
+ * `/v1/public/realtime/token` (use the admin path for staff surfaces).
18
+ */
19
+ tokenEndpoint?: string;
20
+ /** Override the fetch implementation (defaults to credentialed `fetch`). */
21
+ fetcher?: (url: string, init?: RequestInit) => Promise<Response>;
22
+ /** Fully override token retrieval (takes precedence over `tokenEndpoint`). */
23
+ fetchToken?: RealtimeTokenFetcher;
24
+ children: ReactNode;
25
+ }
26
+ export declare function RealtimeReactProvider({ connector, tokenEndpoint, fetcher, fetchToken, children, }: RealtimeReactProviderProps): import("react/jsx-runtime").JSX.Element;
27
+ export declare function useRealtimeContext(): RealtimeReactContextValue;
28
+ //# sourceMappingURL=provider.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../src/provider.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAiB,KAAK,SAAS,EAAuB,MAAM,OAAO,CAAA;AAE1E,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAA;AAEvD,MAAM,MAAM,oBAAoB,GAAG,MAAM,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,CAAC,CAAA;AAEtF,MAAM,WAAW,yBAAyB;IACxC,SAAS,EAAE,iBAAiB,CAAA;IAC5B,2EAA2E;IAC3E,UAAU,EAAE,oBAAoB,CAAA;CACjC;AAID,MAAM,WAAW,0BAA0B;IACzC,yEAAyE;IACzE,SAAS,EAAE,iBAAiB,CAAA;IAC5B;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,4EAA4E;IAC5E,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAA;IAChE,8EAA8E;IAC9E,UAAU,CAAC,EAAE,oBAAoB,CAAA;IACjC,QAAQ,EAAE,SAAS,CAAA;CACpB;AAKD,wBAAgB,qBAAqB,CAAC,EACpC,SAAS,EACT,aAA2C,EAC3C,OAAwB,EACxB,UAAU,EACV,QAAQ,GACT,EAAE,0BAA0B,2CAgB5B;AAED,wBAAgB,kBAAkB,IAAI,yBAAyB,CAQ9D"}
@@ -0,0 +1,27 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { createContext, useContext, useMemo } from "react";
4
+ const RealtimeReactContext = createContext(null);
5
+ const defaultFetcher = (url, init) => fetch(url, { credentials: "include", ...init });
6
+ export function RealtimeReactProvider({ connector, tokenEndpoint = "/v1/public/realtime/token", fetcher = defaultFetcher, fetchToken, children, }) {
7
+ const value = useMemo(() => {
8
+ const resolveToken = fetchToken ??
9
+ (async () => {
10
+ const response = await fetcher(tokenEndpoint, { method: "POST" });
11
+ if (!response.ok) {
12
+ throw new Error(`Realtime token request failed: ${response.status}`);
13
+ }
14
+ const body = (await response.json());
15
+ return { token: body.data.token, expiresAt: body.data.expiresAt };
16
+ });
17
+ return { connector, fetchToken: resolveToken };
18
+ }, [connector, tokenEndpoint, fetcher, fetchToken]);
19
+ return _jsx(RealtimeReactContext.Provider, { value: value, children: children });
20
+ }
21
+ export function useRealtimeContext() {
22
+ const context = useContext(RealtimeReactContext);
23
+ if (!context) {
24
+ throw new Error("useRealtimeContext must be used inside <RealtimeReactProvider>. Wrap your app with <RealtimeReactProvider connector={...} />.");
25
+ }
26
+ return context;
27
+ }
@@ -0,0 +1,24 @@
1
+ import type { QueryKey } from "@tanstack/react-query";
2
+ import type { RealtimeClientMessage } from "./connector.js";
3
+ /**
4
+ * The invalidation hint shape published by the `@voyant-travel/realtime`
5
+ * EventBus bridge.
6
+ */
7
+ export interface RealtimeInvalidationHint {
8
+ event: string;
9
+ entity: string;
10
+ id?: string;
11
+ }
12
+ /**
13
+ * Maps a hint to the React Query keys that should be invalidated. Receiving a
14
+ * `{ entity: "booking", id }` hint typically invalidates both the list and the
15
+ * detail key for that entity.
16
+ */
17
+ export type HintToQueryKeys = (hint: RealtimeInvalidationHint) => ReadonlyArray<QueryKey>;
18
+ /**
19
+ * Pure translation of a channel message into the query keys to invalidate.
20
+ * Returns `[]` when the payload is not a recognisable hint or the map yields
21
+ * nothing — callers can safely spread the result into `invalidateQueries`.
22
+ */
23
+ export declare function resolveInvalidationKeys(message: RealtimeClientMessage, map: HintToQueryKeys): ReadonlyArray<QueryKey>;
24
+ //# sourceMappingURL=query-keys.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"query-keys.d.ts","sourceRoot":"","sources":["../src/query-keys.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,uBAAuB,CAAA;AAErD,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,gBAAgB,CAAA;AAE3D;;;GAGG;AACH,MAAM,WAAW,wBAAwB;IACvC,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;IACd,EAAE,CAAC,EAAE,MAAM,CAAA;CACZ;AAED;;;;GAIG;AACH,MAAM,MAAM,eAAe,GAAG,CAAC,IAAI,EAAE,wBAAwB,KAAK,aAAa,CAAC,QAAQ,CAAC,CAAA;AAWzF;;;;GAIG;AACH,wBAAgB,uBAAuB,CACrC,OAAO,EAAE,qBAAqB,EAC9B,GAAG,EAAE,eAAe,GACnB,aAAa,CAAC,QAAQ,CAAC,CAGzB"}
@@ -0,0 +1,16 @@
1
+ function isHint(value) {
2
+ return (typeof value === "object" &&
3
+ value !== null &&
4
+ typeof value.entity === "string" &&
5
+ typeof value.event === "string");
6
+ }
7
+ /**
8
+ * Pure translation of a channel message into the query keys to invalidate.
9
+ * Returns `[]` when the payload is not a recognisable hint or the map yields
10
+ * nothing — callers can safely spread the result into `invalidateQueries`.
11
+ */
12
+ export function resolveInvalidationKeys(message, map) {
13
+ if (!isHint(message.data))
14
+ return [];
15
+ return map(message.data);
16
+ }
@@ -0,0 +1,21 @@
1
+ import type { PresenceMember, RealtimeClientMessage } from "./connector.js";
2
+ export interface UseChannelOptions {
3
+ /** Called for each message delivered on the channel. */
4
+ onMessage?: (message: RealtimeClientMessage) => void;
5
+ /** Called when the channel's presence set changes. */
6
+ onPresence?: (members: ReadonlyArray<PresenceMember>) => void;
7
+ /** Resume marker for replay-capable vendors. */
8
+ sinceId?: string;
9
+ /** Local presence profile announced to the channel. */
10
+ profile?: unknown;
11
+ /** Set `false` to pause the subscription without unmounting. */
12
+ enabled?: boolean;
13
+ }
14
+ /**
15
+ * Subscribe to a single realtime channel. Mints a token via the provider's
16
+ * token route, opens a connection through the injected connector, and tears it
17
+ * down on unmount / channel change. Vendor-agnostic — the transport is whatever
18
+ * connector the `RealtimeReactProvider` was given.
19
+ */
20
+ export declare function useChannel(channel: string | null | undefined, options?: UseChannelOptions): void;
21
+ //# sourceMappingURL=use-channel.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-channel.d.ts","sourceRoot":"","sources":["../src/use-channel.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,cAAc,EAAE,qBAAqB,EAAE,MAAM,gBAAgB,CAAA;AAG3E,MAAM,WAAW,iBAAiB;IAChC,wDAAwD;IACxD,SAAS,CAAC,EAAE,CAAC,OAAO,EAAE,qBAAqB,KAAK,IAAI,CAAA;IACpD,sDAAsD;IACtD,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,aAAa,CAAC,cAAc,CAAC,KAAK,IAAI,CAAA;IAC7D,gDAAgD;IAChD,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,uDAAuD;IACvD,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,gEAAgE;IAChE,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAED;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAAE,OAAO,GAAE,iBAAsB,QA8B7F"}
@@ -0,0 +1,38 @@
1
+ "use client";
2
+ import { useEffect, useRef } from "react";
3
+ import { useRealtimeContext } from "./provider.js";
4
+ /**
5
+ * Subscribe to a single realtime channel. Mints a token via the provider's
6
+ * token route, opens a connection through the injected connector, and tears it
7
+ * down on unmount / channel change. Vendor-agnostic — the transport is whatever
8
+ * connector the `RealtimeReactProvider` was given.
9
+ */
10
+ export function useChannel(channel, options = {}) {
11
+ const { connector, fetchToken } = useRealtimeContext();
12
+ const { onMessage, onPresence, sinceId, profile, enabled = true } = options;
13
+ // Keep the latest callbacks without re-subscribing on every render.
14
+ const handlers = useRef({ onMessage, onPresence });
15
+ handlers.current = { onMessage, onPresence };
16
+ useEffect(() => {
17
+ if (!channel || !enabled)
18
+ return;
19
+ let connection = null;
20
+ let cancelled = false;
21
+ void fetchToken().then(({ token }) => {
22
+ if (cancelled)
23
+ return;
24
+ connection = connector.subscribe({
25
+ channel,
26
+ token,
27
+ sinceId,
28
+ profile,
29
+ onMessage: (message) => handlers.current.onMessage?.(message),
30
+ onPresence: (members) => handlers.current.onPresence?.(members),
31
+ });
32
+ });
33
+ return () => {
34
+ cancelled = true;
35
+ connection?.unsubscribe();
36
+ };
37
+ }, [channel, enabled, sinceId, profile, connector, fetchToken]);
38
+ }
@@ -0,0 +1,19 @@
1
+ import type { RealtimeClientMessage } from "./connector.js";
2
+ import { type HintToQueryKeys } from "./query-keys.js";
3
+ export interface UseLiveQueriesOptions {
4
+ /** Pause all subscriptions without unmounting. */
5
+ enabled?: boolean;
6
+ /** Observe raw messages in addition to the invalidation behaviour. */
7
+ onMessage?: (channel: string, message: RealtimeClientMessage) => void;
8
+ }
9
+ /**
10
+ * The hook most screens need: subscribe to one or more channels and translate
11
+ * each invalidation hint into `queryClient.invalidateQueries` calls, so
12
+ * existing data-fetching screens go live without rewriting their data layer.
13
+ *
14
+ * `map` turns a hint (`{ entity, id }`) into the React Query keys to refetch.
15
+ * Subscriptions are managed by a single effect (no hooks-in-a-loop); pass a
16
+ * stable `channels` array (memoise in the caller) to avoid re-subscribing.
17
+ */
18
+ export declare function useLiveQueries(channels: ReadonlyArray<string>, map: HintToQueryKeys, options?: UseLiveQueriesOptions): void;
19
+ //# sourceMappingURL=use-live-queries.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-live-queries.d.ts","sourceRoot":"","sources":["../src/use-live-queries.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,qBAAqB,EAAsB,MAAM,gBAAgB,CAAA;AAE/E,OAAO,EAAE,KAAK,eAAe,EAA2B,MAAM,iBAAiB,CAAA;AAE/E,MAAM,WAAW,qBAAqB;IACpC,kDAAkD;IAClD,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,sEAAsE;IACtE,SAAS,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,qBAAqB,KAAK,IAAI,CAAA;CACtE;AAED;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAC5B,QAAQ,EAAE,aAAa,CAAC,MAAM,CAAC,EAC/B,GAAG,EAAE,eAAe,EACpB,OAAO,GAAE,qBAA0B,QA8CpC"}
Binary file
@@ -0,0 +1,7 @@
1
+ import type { PresenceMember } from "./connector.js";
2
+ /**
3
+ * Track the presence member list of a channel ("Ana is viewing this booking").
4
+ * Returns the current members; `profile` is announced as this client's entry.
5
+ */
6
+ export declare function usePresence(channel: string | null | undefined, profile?: unknown): ReadonlyArray<PresenceMember>;
7
+ //# sourceMappingURL=use-presence.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-presence.d.ts","sourceRoot":"","sources":["../src/use-presence.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAA;AAGpD;;;GAGG;AACH,wBAAgB,WAAW,CACzB,OAAO,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAClC,OAAO,CAAC,EAAE,OAAO,GAChB,aAAa,CAAC,cAAc,CAAC,CAM/B"}
@@ -0,0 +1,12 @@
1
+ "use client";
2
+ import { useState } from "react";
3
+ import { useChannel } from "./use-channel.js";
4
+ /**
5
+ * Track the presence member list of a channel ("Ana is viewing this booking").
6
+ * Returns the current members; `profile` is announced as this client's entry.
7
+ */
8
+ export function usePresence(channel, profile) {
9
+ const [members, setMembers] = useState([]);
10
+ useChannel(channel, { profile, onPresence: setMembers });
11
+ return members;
12
+ }
package/package.json ADDED
@@ -0,0 +1,77 @@
1
+ {
2
+ "name": "@voyant-travel/realtime-react",
3
+ "version": "0.1.0",
4
+ "license": "Apache-2.0",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "exports": {
8
+ ".": "./src/index.ts",
9
+ "./provider": "./src/provider.tsx",
10
+ "./connector": "./src/connector.ts",
11
+ "./connector-cloud": "./src/connector-cloud.ts",
12
+ "./query-keys": "./src/query-keys.ts"
13
+ },
14
+ "scripts": {
15
+ "typecheck": "tsc --noEmit",
16
+ "lint": "biome check src/",
17
+ "test": "vitest run --passWithNoTests",
18
+ "build": "tsc -p tsconfig.json",
19
+ "clean": "rm -rf dist tsconfig.tsbuildinfo",
20
+ "prepack": "pnpm run build"
21
+ },
22
+ "peerDependencies": {
23
+ "@tanstack/react-query": "^5.0.0",
24
+ "react": "^19.0.0"
25
+ },
26
+ "devDependencies": {
27
+ "@tanstack/react-query": "^5.100.11",
28
+ "@testing-library/react": "^16.0.0",
29
+ "@types/react": "^19.2.14",
30
+ "@voyant-travel/voyant-typescript-config": "workspace:^",
31
+ "jsdom": "^25.0.0",
32
+ "react": "^19.2.4",
33
+ "react-dom": "^19.2.4",
34
+ "typescript": "^6.0.2",
35
+ "vitest": "^4.1.2"
36
+ },
37
+ "files": [
38
+ "dist"
39
+ ],
40
+ "publishConfig": {
41
+ "access": "public",
42
+ "exports": {
43
+ ".": {
44
+ "types": "./dist/index.d.ts",
45
+ "import": "./dist/index.js",
46
+ "default": "./dist/index.js"
47
+ },
48
+ "./provider": {
49
+ "types": "./dist/provider.d.ts",
50
+ "import": "./dist/provider.js",
51
+ "default": "./dist/provider.js"
52
+ },
53
+ "./connector": {
54
+ "types": "./dist/connector.d.ts",
55
+ "import": "./dist/connector.js",
56
+ "default": "./dist/connector.js"
57
+ },
58
+ "./connector-cloud": {
59
+ "types": "./dist/connector-cloud.d.ts",
60
+ "import": "./dist/connector-cloud.js",
61
+ "default": "./dist/connector-cloud.js"
62
+ },
63
+ "./query-keys": {
64
+ "types": "./dist/query-keys.d.ts",
65
+ "import": "./dist/query-keys.js",
66
+ "default": "./dist/query-keys.js"
67
+ }
68
+ },
69
+ "main": "./dist/index.js",
70
+ "types": "./dist/index.d.ts"
71
+ },
72
+ "repository": {
73
+ "type": "git",
74
+ "url": "https://github.com/voyant-travel/voyant.git",
75
+ "directory": "packages/realtime-react"
76
+ }
77
+ }