diff --git a/package.json b/package.json index 87d29b6a..df63eed2 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "build": "turbo run build", "build-saas": "turbo run build-saas", "dev": "npm run dev-dev --prefix packages/springboard/cli", + "dev-hands": "npm run dev-dev --prefix packages/springboard/cli -- ./apps/small_apps/handraiser/index.ts", "docs": "cd docs && docker compose up", "dev-without-node": "turbo run dev --filter=\"!./apps/jamtools/node\"", "debug-node": "npm run debug --prefix apps/jamtools/node", diff --git a/packages/jamtools/features/modules/hand_raiser_module.tsx b/packages/jamtools/features/modules/hand_raiser_module.tsx index 072e780d..beefed43 100644 --- a/packages/jamtools/features/modules/hand_raiser_module.tsx +++ b/packages/jamtools/features/modules/hand_raiser_module.tsx @@ -1,43 +1,82 @@ import {MidiControlChangeInputResult} from '@jamtools/core/modules/macro_module/macro_handlers/inputs/midi_control_change_input_macro_handler'; -import React from 'react'; +import React, {useEffect, useMemo, useState} from 'react'; import springboard from 'springboard'; import './hand_raiser.css'; +type UserData = { + userId: string; + connected: boolean; + name?: string; + handPosition: number; +} + springboard.registerModule('HandRaiser', {}, async (m) => { const macroModule = m.getModule('macro'); macroModule.setLocalMode(true); const states = await m.createStates({ - handPositions: [0, 0], + roomStateV4: {} as Record, + }); + + const myHandPosition = await m.statesAPI.createUserAgentState('myHandPosition', 0); + + m.hooks.onUserConnect((user, users) => { + console.log('onUserConnect', user, users); + states.roomStateV4.setStateImmer((state) => { + for (const key of Object.keys(state)) { + if (!users.find(u => u.id === key)) { + delete state[key]; + } + } + + state[user.id] = { + userId: user.id, + name: user.id, + handPosition: state[user.id]?.handPosition || 0, + connected: true, + }; + }); + }); + + m.hooks.onUserDisconnect((user, users) => { + // console.log('onUserDisconnect', user, users); + states.roomStateV4.setStateImmer((state) => { + delete state[user.id]; + }); }); const actions = m.createActions({ - changeHandPosition: async (args: {index: number, value: number}) => { - states.handPositions.setStateImmer((positions) => { - positions[args.index] = args.value; + changeHandPosition: async (args: {value: number}, options, userData) => { + if (!userData?.userId) { + return; + } + + states.roomStateV4.setStateImmer((users) => { + users[userData.userId].handPosition = args.value; }); }, + getMyUserId: async (args, options, userData) => { + console.log('getMyUserId', args, options, userData); + return { + userId: userData?.userId || '', + }; + }, }); + let originalMyId = ''; + if (m.deps.core.rpc.remote.role === 'client') { + originalMyId = (await actions.getMyUserId()).userId; + } + const macros = await macroModule.createMacros(m, { - slider0: { + handMovingSlider: { type: 'midi_control_change_input', config: { onTrigger: (midiEvent => { if (midiEvent.event.value) { - actions.changeHandPosition({index: 0, value: midiEvent.event.value}); - } - }), - } - }, - slider1: { - type: 'midi_control_change_input', - config: { - onTrigger: (midiEvent => { - if (midiEvent.event.value) { - actions.changeHandPosition({index: 1, value: midiEvent.event.value}); + actions.changeHandPosition({value: midiEvent.event.value}); } }), } @@ -45,18 +84,32 @@ springboard.registerModule('HandRaiser', {}, async (m) => { }); m.registerRoute('/', {}, () => { - const positions = states.handPositions.useState(); + const roomState = states.roomStateV4.useState(); + const [myId, setMyId] = useState(originalMyId); + + const connectedUsers = useMemo(() => Object.values(roomState).filter(u => u.connected), [roomState]); + + useEffect(() => { + (async () => { + setMyId((await actions.getMyUserId()).userId); + })(); + }, [connectedUsers]); return (
- {positions.map((position, index) => ( - actions.changeHandPosition({index, value})} - macro={index === 0 ? macros.slider0 : macros.slider1} - /> +
{myId}
+ {connectedUsers.map((user) => ( + <> + actions.changeHandPosition({value})} + macro={macros.handMovingSlider} + showSlider={user.userId === myId} + /> +
{JSON.stringify(user, null, 2)}
+ ))}
@@ -68,6 +121,7 @@ type HandRaiserModuleProps = { position: number; handlePositionChange: (position: number) => void; macro: MidiControlChangeInputResult; + showSlider: boolean; }; const HandSliderContainer = (props: HandRaiserModuleProps) => { @@ -76,16 +130,18 @@ const HandSliderContainer = (props: HandRaiserModuleProps) => { -
- -
- Configure MIDI - -
-
+ {props.showSlider && ( +
+ +
+ Configure MIDI + +
+
+ )} ); }; diff --git a/packages/springboard/core/engine/engine.tsx b/packages/springboard/core/engine/engine.tsx index 3b285957..4b8221b7 100644 --- a/packages/springboard/core/engine/engine.tsx +++ b/packages/springboard/core/engine/engine.tsx @@ -163,7 +163,7 @@ export class Springboard { api: ModuleReturnValue }> => { const mod: Module = {moduleId}; - const moduleAPI = new ModuleAPI(mod, 'engine', this.coreDeps, this.makeDerivedDependencies(), this.extraModuleDependencies, options); + const moduleAPI = new ModuleAPI(mod, 'engine', this.coreDeps, this.makeDerivedDependencies(), this.extraModuleDependencies, options, this.hooks); const moduleReturnValue = await cb(moduleAPI); Object.assign(mod, moduleReturnValue); @@ -186,12 +186,42 @@ export class Springboard { }; }; + private hookCallbacks = { + onUserConnect: [] as ((data: UserConnectData, users: UserConnectData[]) => void)[], + onUserDisconnect: [] as ((data: UserConnectData, users: UserConnectData[]) => void)[], + } + + public hooks = { + onUserConnect: (callback: (data: UserConnectData, users: UserConnectData[]) => void) => { + this.hookCallbacks.onUserConnect.push(callback); + }, + onUserDisconnect: (callback: (data: UserConnectData, users: UserConnectData[]) => void) => { + this.hookCallbacks.onUserDisconnect.push(callback); + }, + // onReconnect: (callback: (data: UserConnectData) => void) => { + // this.hookCallbacks.onReconnect.push(callback); + // }, + }; + + public hookTriggers = { + handleUserConnect: (data: UserConnectData, users: UserConnectData[]) => { + for (const callback of this.hookCallbacks.onUserConnect) { + callback(data, users); + } + }, + handleUserDisconnect: (data: UserConnectData, users: UserConnectData[]) => { + for (const callback of this.hookCallbacks.onUserDisconnect) { + callback(data, users); + } + }, + } + public registerClassModule = async (cb: ClassModuleCallback): Promise => { const modDependencies = this.makeDerivedDependencies(); const mod = await Promise.resolve(cb(this.coreDeps, modDependencies)); - const moduleAPI = new ModuleAPI(mod, 'engine', this.coreDeps, modDependencies, this.extraModuleDependencies, {}); + const moduleAPI = new ModuleAPI(mod, 'engine', this.coreDeps, modDependencies, this.extraModuleDependencies, {}, this.hooks); if (!isModuleEnabled(mod)) { return null; @@ -286,3 +316,7 @@ const Loader = () => { ); }; + +type UserConnectData = { + id: string; +} diff --git a/packages/springboard/core/engine/module_api.ts b/packages/springboard/core/engine/module_api.ts index 5615d0ea..e562ee9b 100644 --- a/packages/springboard/core/engine/module_api.ts +++ b/packages/springboard/core/engine/module_api.ts @@ -2,6 +2,7 @@ import {SharedStateSupervisor, StateSupervisor, UserAgentStateSupervisor} from ' import {ExtraModuleDependencies, Module, NavigationItemConfig, RegisteredRoute} from 'springboard/module_registry/module_registry'; import {CoreDependencies, ModuleDependencies} from '../types/module_types'; import {RegisterRouteOptions} from './register'; +import {Springboard} from './engine'; type ActionConfigOptions = object; @@ -9,10 +10,14 @@ export type ActionCallOptions = { mode?: 'local' | 'remote'; } +export type UserData = { + userId: string; +} + /** * The Action callback */ -type ActionCallback = Promise> = (args: Args, options?: ActionCallOptions) => ReturnValue; +type ActionCallback = Promise> = (args: Args, options?: ActionCallOptions, userData?: UserData) => ReturnValue; // this would make it so modules/plugins can extend the module API dynamically through interface merging // export interface ModuleAPI { @@ -51,7 +56,15 @@ export class ModuleAPI { public readonly deps: {core: CoreDependencies; module: ModuleDependencies, extra: ExtraModuleDependencies}; - constructor(private module: Module, private prefix: string, private coreDeps: CoreDependencies, private modDeps: ModuleDependencies, extraDeps: ExtraModuleDependencies, private options: ModuleOptions) { + constructor( + private module: Module, + private prefix: string, + private coreDeps: CoreDependencies, + private modDeps: ModuleDependencies, + extraDeps: ExtraModuleDependencies, + private options: ModuleOptions, + public hooks: Springboard['hooks'], + ) { this.deps = {core: coreDeps, module: modDeps, extra: extraDeps}; } @@ -132,7 +145,7 @@ export class ModuleAPI { actionName: string, options: Options, cb: undefined extends Args ? ActionCallback : ActionCallback - ): undefined extends Args ? ((args?: Args, options?: ActionCallOptions) => ReturnValue) : ((args: Args, options?: ActionCallOptions) => ReturnValue) => { + ): undefined extends Args ? ((args?: Args, options?: ActionCallOptions, userData?: UserData) => ReturnValue) : ((args: Args, options?: ActionCallOptions, userData?: UserData) => ReturnValue) => { const fullActionName = `${this.fullPrefix}|action|${actionName}`; if (this.coreDeps.rpc.remote.role === 'server') { @@ -143,7 +156,7 @@ export class ModuleAPI { this.coreDeps.rpc.local.registerRpc(fullActionName, cb); } - return (async (args: Args, options?: ActionCallOptions): Promise> => { + return (async (args: Args, options?: ActionCallOptions, userData?: UserData): Promise> => { try { let rpc = this.coreDeps.rpc.remote; @@ -151,7 +164,7 @@ export class ModuleAPI { if (this.coreDeps.isMaestro() || this.options.rpcMode === 'local' || options?.mode === 'local') { if (!this.coreDeps.rpc.local || this.coreDeps.rpc.local.role !== 'client') { - return await cb(args); + return await cb(args, options, userData); } rpc = this.coreDeps.rpc.local!; diff --git a/packages/springboard/platforms/node/services/node_json_rpc.ts b/packages/springboard/platforms/node/services/node_json_rpc.ts deleted file mode 100644 index 5639da8f..00000000 --- a/packages/springboard/platforms/node/services/node_json_rpc.ts +++ /dev/null @@ -1,130 +0,0 @@ -import {JSONRPCClient, JSONRPCServer} from 'json-rpc-2.0'; -import WebSocket from 'isomorphic-ws'; -import ReconnectingWebSocket from 'reconnecting-websocket'; - -import {KVStore, Rpc, RpcArgs} from 'springboard/types/module_types'; - -type ClientParams = { - clientId: string; -} - -export class NodeJsonRpcClientAndServer implements Rpc { - rpcClient!: JSONRPCClient; - rpcServer!: JSONRPCServer; - - constructor (private url: string, private sessionStore: KVStore) {} - - private clientId = ''; - - public role = 'client' as const; - - initialize = async (): Promise => { - this.clientId = await this.getClientId(); - - this.rpcServer = new JSONRPCServer(); - - await new Promise(r => setTimeout(r, 1000)); - - const connected = await this.initializeWebsocket(); - return connected; - }; - - private getClientId = async () => { - if (this.clientId) { - return this.clientId; - } - - const fromStorage = await this.sessionStore.get('ws-client-id'); - if (fromStorage) { - this.clientId = fromStorage; - return this.clientId; - } - - const newClientId = Math.random().toString().slice(2); - this.clientId = newClientId; - - await this.sessionStore.set('ws-client-id', newClientId); - - return this.clientId; - }; - - registerRpc = (method: string, cb: (args: Args) => Promise) => { - this.rpcServer.addMethod(method, async (args) => { - const result = await cb(args); - // console.log(`received RPC call for ${method}. Returned:`, result) - return result; - }); - }; - - callRpc = async (method: string, args: Args): Promise => { - // console.log('calling rpc', method, JSON.stringify(args)); - - const params = {clientId: this.clientId}; - const result = await this.rpcClient.request(method, args, params); - return result; - }; - - broadcastRpc = async (method: string, args: Args, _rpcArgs?: RpcArgs | undefined): Promise => { - // console.log('broadcasting rpc', method, JSON.stringify(args)); - - const params = {clientId: this.clientId}; - return this.rpcClient.notify(method, args, params); - }; - - // TODO: if we fail to connect on startup, we should just exit the program with a friendly error - // or at least not spit out the massive error object we currently do - initializeWebsocket = async () => { - const separator = this.url.includes('?') ? '&' : '?'; - const fullUrl = `${this.url}${separator}clientId=${this.clientId}`; - const ws = new ReconnectingWebSocket(fullUrl, undefined, {WebSocket}); - - ws.onmessage = async (event) => { - const jsonMessage = JSON.parse(event.data.toString()); - - if (jsonMessage.jsonrpc === '2.0' && jsonMessage.method) { - // Handle incoming RPC requests coming from the server to run in this client - const result = await this.rpcServer.receive(jsonMessage); - if (result) { - (result as any).clientId = (jsonMessage as unknown as any).clientId; - } - ws.send(JSON.stringify(result)); - } else { - // Handle incoming RPC responses after calling an rpc method on the server - // console.log(jsonMessage); - this.rpcClient.receive(jsonMessage); - } - }; - - return new Promise((resolve, _reject) => { - let connected = false; - - ws.onopen = () => { - connected = true; - console.log('websocket connected'); - this.rpcClient = new JSONRPCClient(async (request) => { - request.clientId = this.clientId; - if (ws.readyState === WebSocket.OPEN) { - // console.log(request); - ws.send(JSON.stringify(request)); - return Promise.resolve(); - } else { - return Promise.reject(new Error('WebSocket is not open')); - } - }); - resolve(true); - }; - - ws.onerror = async (e) => { - if (!connected) { - // console.error('failed to connect to websocket'); - this.rpcClient = new JSONRPCClient(() => { - return Promise.reject(new Error('WebSocket is not open')); - }); - resolve(false); - } - - console.error('Error with websocket', e); - }; - }); - }; -} diff --git a/packages/springboard/platforms/node/services/node_local_json_rpc.ts b/packages/springboard/platforms/node/services/node_local_json_rpc.ts index ef8bf1a2..e21786ef 100644 --- a/packages/springboard/platforms/node/services/node_local_json_rpc.ts +++ b/packages/springboard/platforms/node/services/node_local_json_rpc.ts @@ -1,4 +1,5 @@ import {JSONRPCClient, JSONRPCServer} from 'json-rpc-2.0'; +import {ActionCallOptions, UserData} from 'springboard/engine/module_api'; import {Rpc, RpcArgs} from 'springboard/types/module_types'; @@ -23,9 +24,9 @@ export class NodeLocalJsonRpcClientAndServer implements Rpc { return true; }; - registerRpc = (method: string, cb: (args: Args) => Promise) => { - this.rpcServer.addMethod(method, async (args) => { - const result = await cb(args); + registerRpc = (method: string, cb: (args: Args, options: ActionCallOptions, userData?: UserData) => Promise) => { + this.rpcServer.addMethod(method, async (args: {params: Args} & {userData?: UserData}) => { + const result = await cb(args.params, {mode: 'remote'}, args.userData); return result; }); }; @@ -39,8 +40,20 @@ export class NodeLocalJsonRpcClientAndServer implements Rpc { return this.rpcClient.notify(method, args); }; - public processRequest = async (jsonMessageStr: string) => { - const jsonMessage = JSON.parse(jsonMessageStr); + public processRequest = async (jsonMessageStr: string, userData: UserData) => { + let jsonMessage = JSON.parse(jsonMessageStr) as { + jsonrpc: '2.0'; + id: number; + method: string; + params?: any; + clientId: string; + }; + + const originalParams = jsonMessage.params; + jsonMessage.params = { + params: originalParams, + userData, + } const result = await this.rpcServer.receive(jsonMessage); if (result) { diff --git a/packages/springboard/platforms/partykit/src/entrypoints/partykit_server_entrypoint.ts b/packages/springboard/platforms/partykit/src/entrypoints/partykit_server_entrypoint.ts index 71797b04..ecd490ae 100644 --- a/packages/springboard/platforms/partykit/src/entrypoints/partykit_server_entrypoint.ts +++ b/packages/springboard/platforms/partykit/src/entrypoints/partykit_server_entrypoint.ts @@ -21,6 +21,12 @@ export default class Server implements Party.Server { private springboardApp!: Springboard; private rpcService: PartykitJsonRpcServer; + connectedUsers: Record = {}; + + options: Party.ServerOptions = { + hibernate: true, + }; + private kv: Record = {}; constructor(readonly room: Party.Room) { @@ -34,6 +40,16 @@ export default class Server implements Party.Server { this.rpcService = rpcService; } + async onConnect(connection: Party.Connection, ctx: Party.ConnectionContext) { + this.connectedUsers[connection.id] = connection; + this.springboardApp.hookTriggers.handleUserConnect({id: connection.id}, Object.values(this.connectedUsers)); + } + + async onDisconnect(connection: Party.Connection, ctx: Party.ConnectionContext) { + delete this.connectedUsers[connection.id]; + this.springboardApp.hookTriggers.handleUserDisconnect({id: connection.id}, Object.values(this.connectedUsers)); + } + async onStart() { springboard.reset(); const values = await this.room.storage.list({ diff --git a/packages/springboard/server/src/hono_app.ts b/packages/springboard/server/src/hono_app.ts index 083bc77f..fdbdd220 100644 --- a/packages/springboard/server/src/hono_app.ts +++ b/packages/springboard/server/src/hono_app.ts @@ -31,8 +31,8 @@ export const initApp = (coreDeps: WebsocketServerCoreDependencies): InitAppRetur app.use('*', cors()); const service: NodeJsonRpcServer = new NodeJsonRpcServer({ - processRequest: async (message) => { - return rpc!.processRequest(message); + processRequest: async (message, userData) => { + return rpc!.processRequest(message, userData); }, rpcMiddlewares, }); @@ -153,6 +153,7 @@ export const initApp = (coreDeps: WebsocketServerCoreDependencies): InitAppRetur } storedEngine = engine; + service.setHookTriggers(engine.hookTriggers); }, }; diff --git a/packages/springboard/server/src/services/server_json_rpc.ts b/packages/springboard/server/src/services/server_json_rpc.ts index c49ea4a4..e53345f7 100644 --- a/packages/springboard/server/src/services/server_json_rpc.ts +++ b/packages/springboard/server/src/services/server_json_rpc.ts @@ -4,22 +4,29 @@ import {WSContext, WSEvents} from 'hono/ws'; import {RpcMiddleware} from '@/register'; import {nodeRpcAsyncLocalStorage} from '@springboardjs/platforms-node/services/node_rpc_async_local_storage'; +import {Springboard} from 'springboard/engine/engine'; +import {UserData} from 'springboard/engine/module_api'; type WebsocketInterface = { send: (s: string) => void; } type NodeJsonRpcServerInitArgs = { - processRequest: (message: string) => Promise; + processRequest: (message: string, userData: UserData) => Promise; rpcMiddlewares: RpcMiddleware[]; } export class NodeJsonRpcServer { private incomingClients: {[clientId: string]: WebsocketInterface} = {}; private outgoingClients: {[clientId: string]: JSONRPCClient} = {}; + private hookTriggers?: Springboard['hookTriggers']; constructor(private initArgs: NodeJsonRpcServerInitArgs) { } + public setHookTriggers(hookTriggers: Springboard['hookTriggers']) { + this.hookTriggers = hookTriggers; + } + // New function: this will be used for async things like toasts // public sendMessage = (message: string, clientId: string) => { // this.incomingClients[clientId]?.send(message); @@ -64,6 +71,7 @@ export class NodeJsonRpcServer { onOpen: (event, ws) => { incomingClients[clientId] = ws; wsStored = ws; + this.hookTriggers?.handleUserConnect({id: clientId}, Object.keys(incomingClients).map(id => ({id}))); }, onMessage: async (event, ws) => { const message = event.data.toString(); @@ -105,7 +113,7 @@ export class NodeJsonRpcServer { } nodeRpcAsyncLocalStorage.run(rpcContext, async () => { - const response = await this.initArgs.processRequest(message); + const response = await this.initArgs.processRequest(message, {userId: clientId}); incomingClients[clientId]?.send(response); }); })(); @@ -113,6 +121,9 @@ export class NodeJsonRpcServer { onClose: () => { delete incomingClients[clientId]; delete outgoingClients[clientId]; + + const connectedUsers = Object.keys(incomingClients).map(id => ({id})).filter(id => id !== clientId); + this.hookTriggers?.handleUserDisconnect({id: clientId}, connectedUsers); }, }; };