@openlivesync/client 1.0.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.
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Wire protocol types for @openlivesync/client.
3
+ * Must stay in sync with @openlivesync/server protocol (same message types and payload shapes).
4
+ */
5
+
6
+ /** Generic presence payload (cursor, name, color, etc.). Server does not interpret. */
7
+ export type Presence = Record<string, unknown>;
8
+
9
+ /** User/session info attached by server from auth (optional). */
10
+ export interface UserInfo {
11
+ userId?: string;
12
+ name?: string;
13
+ email?: string;
14
+ provider?: string;
15
+ [key: string]: unknown;
16
+ }
17
+
18
+ // ----- Client → Server message types -----
19
+
20
+ export const MSG_JOIN_ROOM = "join_room";
21
+ export const MSG_LEAVE_ROOM = "leave_room";
22
+ export const MSG_UPDATE_PRESENCE = "update_presence";
23
+ export const MSG_BROADCAST_EVENT = "broadcast_event";
24
+ export const MSG_SEND_CHAT = "send_chat";
25
+
26
+ export interface JoinRoomPayload {
27
+ roomId: string;
28
+ presence?: Presence;
29
+ /** Optional OAuth/OpenID access token; server decodes to get name, email, provider. */
30
+ accessToken?: string;
31
+ }
32
+
33
+ export interface LeaveRoomPayload {
34
+ roomId?: string;
35
+ }
36
+
37
+ export interface UpdatePresencePayload {
38
+ presence: Presence;
39
+ }
40
+
41
+ export interface BroadcastEventPayload {
42
+ event: string;
43
+ payload?: unknown;
44
+ }
45
+
46
+ export interface SendChatPayload {
47
+ message: string;
48
+ /** Optional application-defined metadata */
49
+ metadata?: Record<string, unknown>;
50
+ }
51
+
52
+ export type ClientMessage =
53
+ | { type: typeof MSG_JOIN_ROOM; payload: JoinRoomPayload }
54
+ | { type: typeof MSG_LEAVE_ROOM; payload?: LeaveRoomPayload }
55
+ | { type: typeof MSG_UPDATE_PRESENCE; payload: UpdatePresencePayload }
56
+ | { type: typeof MSG_BROADCAST_EVENT; payload: BroadcastEventPayload }
57
+ | { type: typeof MSG_SEND_CHAT; payload: SendChatPayload };
58
+
59
+ // ----- Server → Client message types -----
60
+
61
+ export const MSG_ROOM_JOINED = "room_joined";
62
+ export const MSG_PRESENCE_UPDATED = "presence_updated";
63
+ export const MSG_BROADCAST_EVENT_RELAY = "broadcast_event";
64
+ export const MSG_CHAT_MESSAGE = "chat_message";
65
+ export const MSG_ERROR = "error";
66
+
67
+ export interface PresenceEntry {
68
+ connectionId: string;
69
+ userId?: string;
70
+ name?: string;
71
+ email?: string;
72
+ provider?: string;
73
+ presence: Presence;
74
+ }
75
+
76
+ export interface RoomJoinedPayload {
77
+ roomId: string;
78
+ connectionId: string;
79
+ presence: Record<string, PresenceEntry>;
80
+ chatHistory?: StoredChatMessage[];
81
+ }
82
+
83
+ export interface PresenceUpdatedPayload {
84
+ roomId: string;
85
+ joined?: PresenceEntry[];
86
+ left?: string[];
87
+ updated?: PresenceEntry[];
88
+ }
89
+
90
+ export interface BroadcastEventRelayPayload {
91
+ roomId: string;
92
+ connectionId: string;
93
+ userId?: string;
94
+ event: string;
95
+ payload?: unknown;
96
+ }
97
+
98
+ export interface ChatMessagePayload {
99
+ roomId: string;
100
+ connectionId: string;
101
+ userId?: string;
102
+ message: string;
103
+ metadata?: Record<string, unknown>;
104
+ id?: string;
105
+ createdAt?: number;
106
+ }
107
+
108
+ export interface StoredChatMessage {
109
+ id: string;
110
+ roomId: string;
111
+ connectionId: string;
112
+ userId?: string;
113
+ message: string;
114
+ metadata?: Record<string, unknown>;
115
+ createdAt: number;
116
+ }
117
+
118
+ export interface ErrorPayload {
119
+ code: string;
120
+ message: string;
121
+ }
122
+
123
+ export type ServerMessage =
124
+ | { type: typeof MSG_ROOM_JOINED; payload: RoomJoinedPayload }
125
+ | { type: typeof MSG_PRESENCE_UPDATED; payload: PresenceUpdatedPayload }
126
+ | { type: typeof MSG_BROADCAST_EVENT_RELAY; payload: BroadcastEventRelayPayload }
127
+ | { type: typeof MSG_CHAT_MESSAGE; payload: ChatMessagePayload }
128
+ | { type: typeof MSG_ERROR; payload: ErrorPayload };
129
+
130
+ /** Chat message as provided when appending (before storage adds id/createdAt). */
131
+ export interface ChatMessageInput {
132
+ roomId: string;
133
+ connectionId: string;
134
+ userId?: string;
135
+ message: string;
136
+ metadata?: Record<string, unknown>;
137
+ }
@@ -0,0 +1,230 @@
1
+ /**
2
+ * React bindings for @openlivesync/client.
3
+ * Provider + hooks; import from "@openlivesync/client/react".
4
+ */
5
+
6
+ import React, {
7
+ createContext,
8
+ useCallback,
9
+ useContext,
10
+ useEffect,
11
+ useMemo,
12
+ useRef,
13
+ useState,
14
+ type ReactNode,
15
+ } from "react";
16
+ import {
17
+ createLiveSyncClient,
18
+ type LiveSyncClient,
19
+ type LiveSyncClientConfig,
20
+ type LiveSyncClientState,
21
+ } from "./client.js";
22
+ import type { Presence, PresenceEntry, StoredChatMessage } from "./protocol.js";
23
+
24
+ const LiveSyncContext = createContext<LiveSyncClient | null>(null);
25
+
26
+ export interface LiveSyncProviderProps {
27
+ children: ReactNode;
28
+ /** Pre-created client (call createLiveSyncClient yourself). */
29
+ client?: LiveSyncClient;
30
+ /** Or pass config and the provider will create the client and connect on mount. */
31
+ url?: string;
32
+ reconnect?: boolean;
33
+ reconnectIntervalMs?: number;
34
+ maxReconnectIntervalMs?: number;
35
+ getAuthToken?: () => string | Promise<string>;
36
+ presenceThrottleMs?: number;
37
+ }
38
+
39
+ /**
40
+ * Provides the LiveSync client to the tree. Pass either `client` or config (`url`, etc.).
41
+ * If config is passed, the client is created on mount and connect() is called; disconnect on unmount.
42
+ */
43
+ export function LiveSyncProvider(props: LiveSyncProviderProps) {
44
+ const { children, client: clientProp } = props;
45
+ const configRef = useRef<LiveSyncClientConfig | null>(null);
46
+ if (!configRef.current && !clientProp && props.url) {
47
+ configRef.current = {
48
+ url: props.url,
49
+ reconnect: props.reconnect,
50
+ reconnectIntervalMs: props.reconnectIntervalMs,
51
+ maxReconnectIntervalMs: props.maxReconnectIntervalMs,
52
+ getAuthToken: props.getAuthToken,
53
+ presenceThrottleMs: props.presenceThrottleMs,
54
+ };
55
+ }
56
+ const config = configRef.current;
57
+
58
+ const clientFromConfig = useMemo(() => {
59
+ if (clientProp || !config) return null;
60
+ return createLiveSyncClient(config);
61
+ }, [clientProp, config?.url]);
62
+
63
+ const client = clientProp ?? clientFromConfig;
64
+
65
+ useEffect(() => {
66
+ if (!client) return;
67
+ if (!clientProp && config) {
68
+ client.connect();
69
+ return () => client.disconnect();
70
+ }
71
+ }, [client, clientProp, config]);
72
+
73
+ if (!client) {
74
+ throw new Error(
75
+ "LiveSyncProvider: pass either `client` or config (e.g. `url`) to create the client."
76
+ );
77
+ }
78
+
79
+ return React.createElement(LiveSyncContext.Provider, { value: client }, children);
80
+ }
81
+
82
+ export function useLiveSyncClient(): LiveSyncClient {
83
+ const client = useContext(LiveSyncContext);
84
+ if (!client) {
85
+ throw new Error("useLiveSyncClient must be used within LiveSyncProvider");
86
+ }
87
+ return client;
88
+ }
89
+
90
+ function useClientState(): LiveSyncClientState {
91
+ const client = useLiveSyncClient();
92
+ const [state, setState] = useState<LiveSyncClientState>(() =>
93
+ client.getState()
94
+ );
95
+ useEffect(() => {
96
+ return client.subscribe(setState);
97
+ }, [client]);
98
+ return state;
99
+ }
100
+
101
+ /** Returns current connection status and triggers re-renders when it changes. */
102
+ export function useConnectionStatus(): LiveSyncClientState["connectionStatus"] {
103
+ return useClientState().connectionStatus;
104
+ }
105
+
106
+ export interface UseRoomOptions {
107
+ /** Initial presence when joining (optional). */
108
+ initialPresence?: Presence;
109
+ /** If true, join the room when roomId is set and leave on cleanup or when roomId changes. */
110
+ autoJoin?: boolean;
111
+ /** Optional access token sent with join_room (server can decode for name/email). */
112
+ accessToken?: string;
113
+ /** Optional getter for access token (e.g. refreshed token); used when auto-joining. */
114
+ getAccessToken?: () => string | Promise<string>;
115
+ }
116
+
117
+ export interface UseRoomReturn {
118
+ join: (roomId: string, presence?: Presence, accessToken?: string) => void;
119
+ leave: (roomId?: string) => void;
120
+ updatePresence: (presence: Presence) => void;
121
+ broadcastEvent: (event: string, payload?: unknown) => void;
122
+ presence: Record<string, PresenceEntry>;
123
+ connectionId: string | null;
124
+ isInRoom: boolean;
125
+ currentRoomId: string | null;
126
+ }
127
+
128
+ /**
129
+ * Subscribe to room state and get methods to join/leave/update presence/broadcast.
130
+ * If autoJoin is true (default), joining happens when roomId is set and leaving on unmount or roomId change.
131
+ */
132
+ export function useRoom(
133
+ roomId: string | null,
134
+ options: UseRoomOptions = {}
135
+ ): UseRoomReturn {
136
+ const { initialPresence, autoJoin = true, accessToken, getAccessToken } = options;
137
+ const client = useLiveSyncClient();
138
+ const state = useClientState();
139
+ const joinedRef = useRef<string | null>(null);
140
+
141
+ const join = useCallback(
142
+ (id: string, presence?: Presence, token?: string) => {
143
+ const t = token ?? accessToken;
144
+ client.joinRoom(id, presence ?? initialPresence, t);
145
+ joinedRef.current = id;
146
+ },
147
+ [client, initialPresence, accessToken]
148
+ );
149
+
150
+ const leave = useCallback(
151
+ (id?: string) => {
152
+ client.leaveRoom(id);
153
+ if (!id || id === joinedRef.current) joinedRef.current = null;
154
+ },
155
+ [client]
156
+ );
157
+
158
+ useEffect(() => {
159
+ if (!autoJoin || roomId === null) return;
160
+ let cancelled = false;
161
+ (async () => {
162
+ const token = accessToken ?? (getAccessToken ? await getAccessToken() : undefined);
163
+ if (!cancelled) {
164
+ client.joinRoom(roomId, initialPresence, token);
165
+ joinedRef.current = roomId;
166
+ }
167
+ })();
168
+ return () => {
169
+ cancelled = true;
170
+ leave(roomId);
171
+ };
172
+ }, [autoJoin, roomId, initialPresence, accessToken, getAccessToken, client, leave]);
173
+
174
+ const updatePresence = useCallback(
175
+ (presence: Presence) => client.updatePresence(presence),
176
+ [client]
177
+ );
178
+
179
+ const broadcastEvent = useCallback(
180
+ (event: string, payload?: unknown) => client.broadcastEvent(event, payload),
181
+ [client]
182
+ );
183
+
184
+ const isInRoom =
185
+ state.currentRoomId !== null && state.currentRoomId === roomId;
186
+
187
+ return {
188
+ join,
189
+ leave,
190
+ updatePresence,
191
+ broadcastEvent,
192
+ presence: roomId && isInRoom ? state.presence : {},
193
+ connectionId: isInRoom ? state.connectionId : null,
194
+ isInRoom,
195
+ currentRoomId: state.currentRoomId,
196
+ };
197
+ }
198
+
199
+ /** Returns presence map for the current room (or empty if not in room). */
200
+ export function usePresence(roomId: string | null): Record<string, PresenceEntry> {
201
+ useLiveSyncClient(); // ensure we're inside provider
202
+ const state = useClientState();
203
+ const isInRoom =
204
+ roomId !== null &&
205
+ state.currentRoomId === roomId;
206
+ return isInRoom ? state.presence : {};
207
+ }
208
+
209
+ export interface UseChatReturn {
210
+ messages: StoredChatMessage[];
211
+ sendMessage: (message: string, metadata?: Record<string, unknown>) => void;
212
+ }
213
+
214
+ /** Returns chat messages for the given room and sendMessage. Ensure the room is joined (e.g. via useRoom). */
215
+ export function useChat(roomId: string | null): UseChatReturn {
216
+ const client = useLiveSyncClient();
217
+ const state = useClientState();
218
+ const isInRoom =
219
+ roomId !== null && state.currentRoomId === roomId;
220
+ const messages = isInRoom ? state.chatMessages : [];
221
+
222
+ const sendMessage = useCallback(
223
+ (message: string, metadata?: Record<string, unknown>) => {
224
+ client.sendChat(message, metadata);
225
+ },
226
+ [client]
227
+ );
228
+
229
+ return { messages, sendMessage };
230
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src",
6
+ "module": "ESNext",
7
+ "target": "ES2020",
8
+ "jsx": "react-jsx"
9
+ },
10
+ "include": ["src"]
11
+ }
package/tsup.config.ts ADDED
@@ -0,0 +1,15 @@
1
+ import { defineConfig } from "tsup";
2
+
3
+ export default defineConfig({
4
+ entry: ["src/index.ts", "src/react-entry.tsx"],
5
+ format: ["esm"],
6
+ dts: true,
7
+ sourcemap: true,
8
+ clean: true,
9
+ target: "es2020",
10
+ outDir: "dist",
11
+ external: ["react"],
12
+ esbuildOptions(options) {
13
+ options.jsx = "automatic";
14
+ },
15
+ });