@reactor-team/js-sdk 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,407 @@
1
+ import type {
2
+ ApplicationMessage,
3
+ InternalConnectionStatus,
4
+ GPUMachineAssignmentData,
5
+ } from "./types";
6
+ import type {
7
+ ReactorEvent,
8
+ ReactorStatus,
9
+ ReactorState,
10
+ ReactorError,
11
+ ReactorWaitingInfo,
12
+ } from "../types";
13
+ import { CoordinatorClient } from "./CoordinatorClient";
14
+ import { GPUMachineClient } from "./GPUMachineClient";
15
+ import { z } from "zod";
16
+
17
+ const OptionsSchema = z
18
+ .object({
19
+ directConnection: z
20
+ .object({
21
+ livekitJwtToken: z.string(),
22
+ livekitWsUrl: z.string(),
23
+ })
24
+ .optional(),
25
+ insecureApiKey: z.string().optional(),
26
+ jwtToken: z.string().optional(),
27
+ coordinatorUrl: z.string().optional(),
28
+ modelName: z.string(),
29
+ })
30
+ .refine(
31
+ (data) => data.directConnection || data.insecureApiKey || data.jwtToken,
32
+ {
33
+ message:
34
+ "At least one of directConnection, insecureApiKey, or jwtToken must be provided.",
35
+ }
36
+ );
37
+ export type Options = z.input<typeof OptionsSchema>;
38
+
39
+ type EventHandler = (...args: any[]) => void;
40
+
41
+ export class Reactor {
42
+ private coordinatorClient: CoordinatorClient | undefined; //client for the coordinator
43
+ private machineClient: GPUMachineClient | undefined; //client for the machine instance
44
+ private status: ReactorStatus = "disconnected";
45
+ private coordinatorUrl: string;
46
+ private lastError?: ReactorError;
47
+ private waitingInfo?: ReactorWaitingInfo;
48
+ private jwtToken?: string;
49
+ private insecureApiKey?: string;
50
+ private directConnection?: {
51
+ livekitJwtToken: string;
52
+ livekitWsUrl: string;
53
+ };
54
+ private modelName: string;
55
+ private modelVersion: string;
56
+
57
+ constructor(options: Options) {
58
+ const validatedOptions = OptionsSchema.parse(options);
59
+ this.coordinatorUrl =
60
+ validatedOptions.coordinatorUrl || "ws://localhost:8080/ws";
61
+ this.jwtToken = validatedOptions.jwtToken;
62
+ this.insecureApiKey = validatedOptions.insecureApiKey;
63
+ this.directConnection = validatedOptions.directConnection;
64
+ this.modelName = validatedOptions.modelName;
65
+ // TODO: Use the model version from the options once we have a way to handle multiple versions
66
+ this.modelVersion = "1.0.0";
67
+ }
68
+
69
+ // Generic event map
70
+ private eventListeners: Map<ReactorEvent, Set<EventHandler>> = new Map();
71
+
72
+ // Event Emitter API
73
+ on(event: ReactorEvent, handler: EventHandler) {
74
+ if (!this.eventListeners.has(event)) {
75
+ this.eventListeners.set(event, new Set());
76
+ }
77
+ this.eventListeners.get(event)!.add(handler);
78
+ }
79
+
80
+ off(event: ReactorEvent, handler: EventHandler) {
81
+ this.eventListeners.get(event)?.delete(handler);
82
+ }
83
+
84
+ emit(event: ReactorEvent, ...args: any[]) {
85
+ this.eventListeners.get(event)?.forEach((handler) => handler(...args));
86
+ }
87
+
88
+ /**
89
+ * Public method to send a message to the machine.
90
+ * Automatically wraps the message in an application message.
91
+ * @param message The message to send to the machine.
92
+ * @throws Error if not in ready state
93
+ */
94
+ sendMessage(message: any) {
95
+ // Synchronous validation - throw immediately
96
+ if (process.env.NODE_ENV !== "development" && this.status !== "ready") {
97
+ const errorMessage = `Cannot send message, status is ${this.status}`;
98
+ console.error("[Reactor] Not ready, cannot send message");
99
+ throw new Error(errorMessage);
100
+ }
101
+
102
+ try {
103
+ // Automatically wrap the user-message in an application message.
104
+ const applicationMessage: ApplicationMessage = {
105
+ type: "application",
106
+ data: message,
107
+ };
108
+ this.machineClient?.sendMessage(applicationMessage);
109
+ } catch (error) {
110
+ // Async operational error - emit event only
111
+ console.error("[Reactor] Failed to send message:", error);
112
+ this.createError(
113
+ "MESSAGE_SEND_FAILED",
114
+ `Failed to send message: ${error}`,
115
+ "gpu",
116
+ true
117
+ );
118
+ // Don't re-throw - let the error event handle it
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Connects to the machine via LiveKit and waits for the gpu machine to be ready.
124
+ * Once the machine is ready, the Reactor will establish the LiveKit connection.
125
+ * @param livekitJwtToken The JWT token for LiveKit authentication
126
+ * @param livekitWsUrl The WebSocket URL for LiveKit connection
127
+ */
128
+ private async connectToGPUMachine(
129
+ livekitJwtToken: string,
130
+ livekitWsUrl: string
131
+ ) {
132
+ console.debug("[Reactor] Connecting to machine room...");
133
+
134
+ try {
135
+ this.machineClient = new GPUMachineClient(livekitJwtToken, livekitWsUrl);
136
+
137
+ this.machineClient.on("application", (message: any) => {
138
+ // escalate the message event through the instance, so that the user can listen to it.
139
+ this.emit("newMessage", message);
140
+ });
141
+
142
+ this.machineClient.on(
143
+ "statusChanged",
144
+ (status: InternalConnectionStatus) => {
145
+ switch (status) {
146
+ case "connected":
147
+ this.setStatus("ready");
148
+ break;
149
+ case "disconnected":
150
+ // gpu machine has disconnected. Close any other connection to any other entity
151
+ // such as the coordinator or the LiveKit connection.
152
+ this.disconnect();
153
+ break;
154
+ case "error":
155
+ // gpu machine has errored. Close any other connection to any other entity
156
+ // such as the coordinator or the LiveKit connection.
157
+ this.createError(
158
+ "GPU_CONNECTION_ERROR",
159
+ "GPU machine connection failed",
160
+ "gpu",
161
+ true
162
+ );
163
+ this.disconnect();
164
+ break;
165
+ }
166
+ }
167
+ );
168
+
169
+ this.machineClient.on("fps", (fps: number) => {
170
+ this.emit("fps", fps);
171
+ });
172
+
173
+ this.machineClient.on("streamChanged", (videoTrack) => {
174
+ this.emit("streamChanged", videoTrack);
175
+ });
176
+
177
+ console.debug("[Reactor] About to connect to machine");
178
+ await this.machineClient.connect();
179
+ } catch (error) {
180
+ // For private methods, we can still throw since the caller will handle it appropriately
181
+ throw error;
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Connects to the coordinator and waits for a GPU to be assigned.
187
+ * Once a GPU is assigned, the Reactor will connect to the gpu machine via LiveKit.
188
+ */
189
+ async connect(): Promise<void> {
190
+ console.debug("[Reactor] Connecting, status:", this.status);
191
+
192
+ // Synchronous validation - throw immediately
193
+ if (this.status !== "disconnected")
194
+ throw new Error("Already connected or connecting");
195
+
196
+ if (this.directConnection) {
197
+ return this.connectToGPUMachine(
198
+ this.directConnection.livekitJwtToken,
199
+ this.directConnection.livekitWsUrl
200
+ );
201
+ }
202
+
203
+ this.setStatus("connecting");
204
+
205
+ try {
206
+ console.debug(
207
+ "[Reactor] Connecting to coordinator with authenticated URL"
208
+ );
209
+
210
+ this.coordinatorClient = new CoordinatorClient({
211
+ wsUrl: this.coordinatorUrl,
212
+ jwtToken: this.jwtToken,
213
+ insecureApiKey: this.insecureApiKey,
214
+ modelName: this.modelName,
215
+ modelVersion: this.modelVersion,
216
+ });
217
+
218
+ this.coordinatorClient.on(
219
+ "gpu-machine-assigned",
220
+ async (assignmentData: GPUMachineAssignmentData) => {
221
+ console.debug(
222
+ "[Reactor] GPU machine assigned by coordinator:",
223
+ assignmentData
224
+ );
225
+ try {
226
+ await this.connectToGPUMachine(
227
+ assignmentData.livekitJwtToken,
228
+ assignmentData.livekitWsUrl
229
+ );
230
+ } catch (error) {
231
+ console.error("[Reactor] Failed to connect to GPU machine:", error);
232
+ // Emit error event for async GPU connection failure
233
+ this.createError(
234
+ "GPU_CONNECTION_FAILED",
235
+ `Failed to connect to GPU machine: ${error}`,
236
+ "gpu",
237
+ true
238
+ );
239
+ this.disconnect();
240
+ }
241
+ }
242
+ );
243
+
244
+ this.coordinatorClient.on(
245
+ "waiting-info",
246
+ (waitingData: ReactorWaitingInfo) => {
247
+ console.debug("[Reactor] Waiting info update received:", waitingData);
248
+ // Update waiting info
249
+ this.setWaitingInfo({
250
+ ...this.waitingInfo,
251
+ ...waitingData,
252
+ });
253
+ }
254
+ );
255
+
256
+ this.coordinatorClient.on(
257
+ "statusChanged",
258
+ (newStatus: InternalConnectionStatus) => {
259
+ switch (newStatus) {
260
+ case "connected":
261
+ this.setStatus("waiting");
262
+ // Initialize waiting info when entering waiting state
263
+ this.setWaitingInfo({
264
+ position: undefined,
265
+ estimatedWaitTime: undefined,
266
+ averageWaitTime: undefined,
267
+ });
268
+ break;
269
+ case "disconnected":
270
+ this.disconnect();
271
+ break;
272
+ case "error":
273
+ this.createError(
274
+ "COORDINATOR_CONNECTION_ERROR",
275
+ "Coordinator connection failed",
276
+ "coordinator",
277
+ true
278
+ );
279
+ this.disconnect();
280
+ break;
281
+ }
282
+ }
283
+ );
284
+
285
+ await this.coordinatorClient.connect();
286
+ } catch (error) {
287
+ console.error("[Reactor] Authentication failed:", error);
288
+ this.createError(
289
+ "AUTHENTICATION_FAILED",
290
+ `Authentication failed: ${error}`,
291
+ "coordinator",
292
+ true
293
+ );
294
+ this.setStatus("disconnected");
295
+ // Keep throwing for authentication failures as these are immediate/sync failures
296
+ throw error;
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Disconnects from the coordinator and the gpu machine.
302
+ * Ensures cleanup completes even if individual disconnections fail.
303
+ */
304
+ async disconnect() {
305
+ if (this.status === "disconnected") return;
306
+
307
+ // Disconnect coordinator client with error handling
308
+ if (this.coordinatorClient) {
309
+ try {
310
+ this.coordinatorClient.disconnect();
311
+ } catch (error) {
312
+ console.error("[Reactor] Error disconnecting from coordinator:", error);
313
+ // Continue with cleanup even if coordinator disconnect fails
314
+ }
315
+ this.coordinatorClient = undefined;
316
+ }
317
+
318
+ // Disconnect machine client with error handling
319
+ if (this.machineClient) {
320
+ try {
321
+ await this.machineClient.disconnect();
322
+ } catch (error) {
323
+ console.error("[Reactor] Error disconnecting from GPU machine:", error);
324
+ // Continue with cleanup even if machine disconnect fails
325
+ }
326
+ this.machineClient = undefined;
327
+ }
328
+
329
+ this.setStatus("disconnected");
330
+
331
+ // Clear waiting info when disconnecting
332
+ this.waitingInfo = undefined;
333
+ }
334
+
335
+ private setStatus(newStatus: ReactorStatus) {
336
+ console.debug("[Reactor] Setting status:", newStatus, "from", this.status);
337
+ if (this.status !== newStatus) {
338
+ this.status = newStatus;
339
+ this.emit("statusChanged", newStatus);
340
+ }
341
+ }
342
+
343
+ private setWaitingInfo(newWaitingInfo: ReactorWaitingInfo) {
344
+ console.debug(
345
+ "[Reactor] Setting waiting info:",
346
+ newWaitingInfo,
347
+ "from",
348
+ this.waitingInfo
349
+ );
350
+ if (this.waitingInfo !== newWaitingInfo) {
351
+ this.waitingInfo = newWaitingInfo;
352
+ this.emit("waitingInfoChanged", newWaitingInfo);
353
+ }
354
+ }
355
+
356
+ getStatus(): ReactorStatus {
357
+ return this.status;
358
+ }
359
+
360
+ /**
361
+ * Get the current state including status, error, and waiting info
362
+ */
363
+ getState(): ReactorState {
364
+ return {
365
+ status: this.status,
366
+ waitingInfo: this.waitingInfo,
367
+ lastError: this.lastError,
368
+ };
369
+ }
370
+
371
+ /**
372
+ * Get waiting information when status is 'waiting'
373
+ */
374
+ getWaitingInfo(): ReactorWaitingInfo | undefined {
375
+ return this.waitingInfo;
376
+ }
377
+
378
+ /**
379
+ * Get the last error that occurred
380
+ */
381
+ getLastError(): ReactorError | undefined {
382
+ return this.lastError;
383
+ }
384
+
385
+ /**
386
+ * Create and store an error
387
+ */
388
+ private createError(
389
+ code: string,
390
+ message: string,
391
+ component: "coordinator" | "gpu" | "livekit",
392
+ recoverable: boolean,
393
+ retryAfter?: number
394
+ ) {
395
+ this.lastError = {
396
+ code,
397
+ message,
398
+ timestamp: Date.now(),
399
+ recoverable,
400
+ component,
401
+ retryAfter,
402
+ };
403
+
404
+ // Emit error event
405
+ this.emit("error", this.lastError);
406
+ }
407
+ }
@@ -0,0 +1,163 @@
1
+ import { StoreApi } from "zustand";
2
+ import type { ReactorStatus, ReactorWaitingInfo, ReactorError } from "../types";
3
+ import { Reactor, type Options as ReactorOptions } from "./Reactor";
4
+ import { create } from "zustand/react";
5
+ import { createContext } from "react";
6
+ import type { RemoteVideoTrack } from "livekit-client";
7
+
8
+ export type ReactorStoreApi = ReturnType<typeof createReactorStore>;
9
+
10
+ export interface ReactorState {
11
+ status: ReactorStatus;
12
+ videoTrack: RemoteVideoTrack | null;
13
+ //These are the machine FPS. The machine internally reports to the client the rate at which the frames are
14
+ //being generated. This can be useful for the client to calcolate at which rate to run commands.
15
+ fps?: number;
16
+ waitingInfo?: ReactorWaitingInfo;
17
+ lastError?: ReactorError;
18
+ }
19
+
20
+ export interface ReactorActions {
21
+ sendMessage(message: any): void;
22
+ connect(): Promise<void>;
23
+ disconnect(): Promise<void>;
24
+ }
25
+
26
+ // Internal state not exposed to components
27
+ interface ReactorInternalState {
28
+ reactor: Reactor;
29
+ }
30
+
31
+ export const ReactorContext = createContext<ReactorStoreApi | undefined>(
32
+ undefined
33
+ );
34
+
35
+ export type ReactorStore = ReactorState &
36
+ ReactorActions & {
37
+ internal: ReactorInternalState;
38
+ };
39
+
40
+ export const defaultInitState: ReactorState = {
41
+ status: "disconnected",
42
+ videoTrack: null,
43
+ fps: undefined,
44
+ waitingInfo: undefined,
45
+ lastError: undefined,
46
+ };
47
+
48
+ export interface ReactorInitializationProps extends ReactorOptions {}
49
+
50
+ export const initReactorStore = (
51
+ props: ReactorInitializationProps
52
+ ): ReactorState & ReactorInitializationProps => {
53
+ return {
54
+ ...defaultInitState,
55
+ // These are only used for dev initialization, not exposed in the store
56
+ ...props,
57
+ };
58
+ };
59
+
60
+ export const createReactorStore = (
61
+ initProps: ReactorInitializationProps,
62
+ publicState: ReactorState = defaultInitState
63
+ ): StoreApi<ReactorStore> => {
64
+ console.debug("[ReactorStore] Creating store", {
65
+ coordinatorUrl: initProps.coordinatorUrl,
66
+ initialState: publicState,
67
+ });
68
+
69
+ return create<ReactorStore>()((set, get) => {
70
+ const reactor = new Reactor(initProps);
71
+
72
+ console.debug("[ReactorStore] Setting up event listeners");
73
+
74
+ reactor.on("statusChanged", (newStatus: ReactorStatus) => {
75
+ console.debug("[ReactorStore] Status changed", {
76
+ oldStatus: get().status,
77
+ newStatus,
78
+ });
79
+ set({ status: newStatus });
80
+ });
81
+
82
+ reactor.on("waitingInfoChanged", (newWaitingInfo: ReactorWaitingInfo) => {
83
+ console.debug("[ReactorStore] Waiting info changed", {
84
+ oldWaitingInfo: get().waitingInfo,
85
+ newWaitingInfo,
86
+ });
87
+ set({ waitingInfo: newWaitingInfo });
88
+ });
89
+
90
+ reactor.on("streamChanged", (videoTrack: RemoteVideoTrack | null) => {
91
+ console.debug("[ReactorStore] Stream changed", {
92
+ hasVideoTrack: !!videoTrack,
93
+ videoTrackKind: videoTrack?.kind,
94
+ videoTrackSid: videoTrack?.sid,
95
+ });
96
+ set({ videoTrack: videoTrack });
97
+ });
98
+
99
+ reactor.on("fps", (fps: number) => {
100
+ console.debug("[ReactorStore] FPS updated", { fps });
101
+ set({ fps: fps });
102
+ });
103
+
104
+ reactor.on("error", (error: ReactorError) => {
105
+ console.debug("[ReactorStore] Error occurred", error);
106
+ set({ lastError: error });
107
+ });
108
+
109
+ return {
110
+ ...publicState,
111
+ internal: { reactor },
112
+
113
+ // actions
114
+ onMessage: (handler: (message: any) => void) => {
115
+ console.debug("[ReactorStore] Registering message handler");
116
+
117
+ // Simply register the handler
118
+ get().internal.reactor.on("newMessage", handler);
119
+
120
+ // Return a cleanup function that can be called to unregister
121
+ return () => {
122
+ console.debug("[ReactorStore] Cleaning up message handler");
123
+ get().internal.reactor.off("newMessage", handler);
124
+ };
125
+ },
126
+ sendMessage: (mess: any) => {
127
+ console.debug("[ReactorStore] Sending message", { message: mess });
128
+
129
+ try {
130
+ get().internal.reactor.sendMessage(mess);
131
+ console.debug("[ReactorStore] Message sent successfully");
132
+ } catch (error) {
133
+ console.error("[ReactorStore] Failed to send message:", error);
134
+ throw error;
135
+ }
136
+ },
137
+ connect: async () => {
138
+ console.debug("[ReactorStore] Connect called");
139
+
140
+ try {
141
+ await get().internal.reactor.connect();
142
+ console.debug("[ReactorStore] Connect completed successfully");
143
+ } catch (error) {
144
+ console.error("[ReactorStore] Connect failed:", error);
145
+ throw error;
146
+ }
147
+ },
148
+ disconnect: async () => {
149
+ console.debug("[ReactorStore] Disconnect called", {
150
+ currentStatus: get().status,
151
+ });
152
+
153
+ try {
154
+ await get().internal.reactor.disconnect();
155
+ console.debug("[ReactorStore] Disconnect completed successfully");
156
+ } catch (error) {
157
+ console.error("[ReactorStore] Disconnect failed:", error);
158
+ throw error;
159
+ }
160
+ },
161
+ };
162
+ });
163
+ };
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Internal types for the Reactor SDK.
3
+ */
4
+
5
+ import { z } from "zod";
6
+
7
+ // Base message schemas corresponding to Python Pydantic models
8
+
9
+ export const ApplicationMessageSchema = z.object({
10
+ type: z.literal("application"),
11
+ data: z.any(), // Can be any JSON-serializable data
12
+ });
13
+
14
+ // Internal message that notifies the client of the current push rate of the machine
15
+ export const FPSMessageSchema = z.object({
16
+ type: z.literal("fps"),
17
+ data: z.number(),
18
+ });
19
+
20
+ export const GPUMachineReceiveMessageSchema = z.discriminatedUnion("type", [
21
+ ApplicationMessageSchema,
22
+ FPSMessageSchema,
23
+ ]);
24
+
25
+ export const WelcomeMessageSchema = z.object({
26
+ type: z.literal("welcome"),
27
+ data: z.record(z.string(), z.any()),
28
+ });
29
+
30
+ export const GPUMachineAssignmentDataSchema = z.object({
31
+ livekitWsUrl: z.string(),
32
+ livekitJwtToken: z.string(),
33
+ });
34
+
35
+ export const GPUMachineAssignmentMessageSchema = z.object({
36
+ type: z.literal("gpu-machine-assigned"),
37
+ data: GPUMachineAssignmentDataSchema,
38
+ });
39
+
40
+ export const EchoMessageSchema = z.object({
41
+ type: z.literal("echo"),
42
+ data: z.record(z.string(), z.any()),
43
+ });
44
+
45
+ export const WaitingInfoDataSchema = z.object({
46
+ position: z.number().optional(),
47
+ estimatedWaitTime: z.number().optional(),
48
+ averageWaitTime: z.number().optional(),
49
+ });
50
+
51
+ export const WaitingInfoMessageSchema = z.object({
52
+ type: z.literal("waiting-info"),
53
+ data: WaitingInfoDataSchema,
54
+ });
55
+
56
+ export const CoordinatorMessageSchema = z.discriminatedUnion("type", [
57
+ WelcomeMessageSchema,
58
+ GPUMachineAssignmentMessageSchema,
59
+ EchoMessageSchema,
60
+ WaitingInfoMessageSchema,
61
+ ]);
62
+
63
+ export const GPUMachineSendMessageSchema = z.discriminatedUnion("type", [
64
+ ApplicationMessageSchema,
65
+ ]);
66
+
67
+ export const SessionSetupMessageSchema = z.object({
68
+ type: z.literal("sessionSetup"),
69
+ data: z.object({
70
+ modelName: z.string(),
71
+ modelVersion: z.string().default("latest"),
72
+ }),
73
+ });
74
+
75
+ // TypeScript types derived from schemas
76
+ export type ApplicationMessage = z.infer<typeof ApplicationMessageSchema>;
77
+ export type GPUMachineReceiveMessageSchema = z.infer<
78
+ typeof GPUMachineReceiveMessageSchema
79
+ >;
80
+ export type WelcomeMessage = z.infer<typeof WelcomeMessageSchema>;
81
+ export type GPUMachineAssignmentData = z.infer<
82
+ typeof GPUMachineAssignmentDataSchema
83
+ >;
84
+ export type GPUMachineAssignmentMessage = z.infer<
85
+ typeof GPUMachineAssignmentMessageSchema
86
+ >;
87
+ export type EchoMessage = z.infer<typeof EchoMessageSchema>;
88
+ export type WaitingInfoData = z.infer<typeof WaitingInfoDataSchema>;
89
+ export type WaitingInfoMessage = z.infer<typeof WaitingInfoMessageSchema>;
90
+ export type CoordinatorMessage = z.infer<typeof CoordinatorMessageSchema>;
91
+ export type GPUMachineSendMessage = z.infer<typeof GPUMachineSendMessageSchema>;
92
+ export type SessionSetupMessage = z.infer<typeof SessionSetupMessageSchema>;
93
+
94
+ // Internal connection status for individual components
95
+ export type InternalConnectionStatus =
96
+ | "disconnected"
97
+ | "connecting"
98
+ | "connected"
99
+ | "error";
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from "./core/Reactor";
2
+ export * from "./react/ReactorProvider";
3
+ export * from "./react/ReactorView";
4
+ export * from "./react/hooks";
5
+ export * from "./types";