From cbeec59513dac8a049f27f0b30ac8541bb52b6a1 Mon Sep 17 00:00:00 2001 From: Michael Kochell <6913320+mickmister@users.noreply.github.com> Date: Mon, 10 Nov 2025 02:20:35 -0500 Subject: [PATCH 01/15] add separate createServerState and createServerAction APIs --- .node-version | 2 +- .../app_with_splash_screen.tsx | 2 +- apps/small_apps/package.json | 6 +- .../server_state_edge_cases.tsx | 117 ++++++++ .../state_sync_test/state_sync_test.tsx | 163 +++++++++++ apps/small_apps/tic_tac_toe/tic_tac_toe.tsx | 6 +- apps/small_apps/tsconfig.json | 15 + .../chord_families/chord_families_module.tsx | 2 +- .../midi_button_input_macro_handler.tsx | 2 +- ...idi_control_change_input_macro_handler.tsx | 2 +- .../musical_keyboard_input_macro_handler.tsx | 2 +- ...board_paged_octave_input_macro_handler.tsx | 2 +- .../midi_button_output_macro_handler.tsx | 2 +- ...di_control_change_output_macro_handler.tsx | 2 +- .../musical_keyboard_output_macro_handler.tsx | 2 +- .../modules/macro_module/macro_module.tsx | 6 +- .../macro_module/registered_macro_types.ts | 2 +- .../multi_octave_supervisor.tsx | 2 +- .../single_octave_root_mode_supervisor.tsx | 2 +- .../modules/eventide/eventide_module.tsx | 4 +- .../midi_playback/midi_playback_module.tsx | 2 +- .../ultimate_guitar_module.tsx | 6 +- packages/springboard/cli/package.json | 8 +- packages/springboard/cli/src/build.ts | 8 + .../esbuild_plugin_platform_inject.test.ts | 127 +++++++++ .../esbuild_plugin_platform_inject.ts | 166 +++++++++-- .../springboard/core/engine/module_api.ts | 144 ++++++++-- .../core/modules/files/files_module.tsx | 2 +- .../services/states/shared_state_service.ts | 41 +++ pnpm-lock.yaml | 262 +++++++++++++----- 30 files changed, 964 insertions(+), 145 deletions(-) create mode 100644 apps/small_apps/server_state_edge_cases/server_state_edge_cases.tsx create mode 100644 apps/small_apps/state_sync_test/state_sync_test.tsx create mode 100644 apps/small_apps/tsconfig.json create mode 100644 packages/springboard/cli/src/esbuild_plugins/esbuild_plugin_platform_inject.test.ts diff --git a/.node-version b/.node-version index 016e34ba..c004e356 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -v20.17.0 +v22.20.0 diff --git a/apps/small_apps/app_with_splash_screen/app_with_splash_screen.tsx b/apps/small_apps/app_with_splash_screen/app_with_splash_screen.tsx index e0a46761..cf160f03 100644 --- a/apps/small_apps/app_with_splash_screen/app_with_splash_screen.tsx +++ b/apps/small_apps/app_with_splash_screen/app_with_splash_screen.tsx @@ -50,7 +50,7 @@ const CustomSplashScreen = () => { springboard.registerSplashScreen(CustomSplashScreen); springboard.registerModule('AppWithSplashScreen', {}, async (moduleAPI) => { - const messageState = await moduleAPI.statesAPI.createPersistentState('message', 'Hello from the app with custom splash screen!'); + const messageState = await moduleAPI.statesAPI.createSharedState('message', 'Hello from the app with custom splash screen!'); await new Promise(r => setTimeout(r, 5000)); // fake waiting time diff --git a/apps/small_apps/package.json b/apps/small_apps/package.json index 3294bb00..a531df47 100644 --- a/apps/small_apps/package.json +++ b/apps/small_apps/package.json @@ -2,20 +2,20 @@ "name": "small_apps", "version": "0.0.1-autogenerated", "main": "index.js", - "scripts": { - }, + "scripts": {}, "keywords": [], "author": "", "license": "ISC", "description": "", "dependencies": { - "springboard": "workspace:*", "@jamtools/core": "workspace:*", "@jamtools/features": "workspace:*", "@springboardjs/platforms-browser": "workspace:*", "@springboardjs/platforms-node": "workspace:*", + "better-sqlite3": "^11.3.0", "react": "catalog:", "react-dom": "catalog:", + "springboard": "workspace:*", "springboard-cli": "workspace:*" }, "devDependencies": { diff --git a/apps/small_apps/server_state_edge_cases/server_state_edge_cases.tsx b/apps/small_apps/server_state_edge_cases/server_state_edge_cases.tsx new file mode 100644 index 00000000..5d5f35a0 --- /dev/null +++ b/apps/small_apps/server_state_edge_cases/server_state_edge_cases.tsx @@ -0,0 +1,117 @@ +import React from 'react'; +import springboard from 'springboard'; + +// Test various edge cases for server state and action compilation + +springboard.registerModule('server_state_edge_cases', {}, async (moduleAPI) => { + // Test 1: Multiple server states at once using createServerStates + const serverStates = await moduleAPI.statesAPI.createServerStates({ + userSession: { userId: 'user-123', token: 'secret-token' }, + apiKeys: { stripe: 'sk_test_123', sendgrid: 'SG.xyz' }, + internalCache: { lastSync: Date.now(), data: {} }, + }); + + // Test 2: Single server state + const singleServerState = await moduleAPI.statesAPI.createServerState('config', { + dbPassword: 'super-secret-password', + adminKey: 'admin-key-123', + }); + + // Test 3: Function that returns a function (for actions) + const createHandler = (name: string) => async () => { + console.log(`Handler for ${name} called`); + return { success: true, name }; + }; + + // Test 4: Regular createAction (should be stripped - testing backwards compatibility) + const regularAction1 = moduleAPI.createAction('regular1', {}, async () => { + console.log('Regular action - will be stripped in browser'); + return { data: 'regular' }; + }); + + // Test 5: Singular createServerAction with inline function + const serverAction1 = moduleAPI.createServerAction('serverAction1', {}, async () => { + console.log('This should be removed from client'); + return serverStates.userSession.getState(); + }); + + // Test 6: Singular createServerAction with function that returns a function + const serverAction2 = moduleAPI.createServerAction('serverAction2', {}, createHandler('test')); + + // Test 7: Singular createServerAction with variable reference + const myHandler = async () => { + console.log('Variable handler'); + return singleServerState.getState(); + }; + const serverAction3 = moduleAPI.createServerAction('serverAction3', {}, myHandler); + + // Test 8: Mix of createActions (regular - for backwards compat testing) + const regularActions = moduleAPI.createActions({ + // Inline arrow function + inlineArrow: async () => { + console.log('Regular action that will be stripped'); + return { type: 'regular' }; + }, + + // Inline async function + inlineAsync: async function() { + return { data: 'async regular' }; + }, + }); + + // Test 9: createServerActions (plural) with various patterns + const serverActions = moduleAPI.createServerActions({ + // Server action with inline logic + authenticate: async () => { + const session = serverStates.userSession.getState(); + console.log('Authenticating user:', session.userId); + return { authenticated: true, userId: session.userId }; + }, + + // Server action with nested logic + authorize: async () => { + const keys = serverStates.apiKeys.getState(); + console.log('Authorizing with keys'); + return { authorized: true, hasStripeKey: !!keys.stripe }; + }, + + // Server action accessing server state + getSecrets: async () => { + const config = singleServerState.getState(); + return { hasPassword: !!config.dbPassword }; + }, + }); + + // UI Component to verify behavior + const EdgeCasesUI: React.FC = () => { + return ( +
+

Server State Edge Cases Test

+
+

Regular Actions (backwards compat):

+ + + +
+
+

Server Actions:

+ + + + + + +
+
+

Expected Behavior:

+
    +
  • Browser Build: All server states removed, all action bodies empty
  • +
  • Server Build: Everything intact with full implementation
  • +
+
+
+ ); + }; + + moduleAPI.registerRoute('/', {}, () => ); +}); diff --git a/apps/small_apps/state_sync_test/state_sync_test.tsx b/apps/small_apps/state_sync_test/state_sync_test.tsx new file mode 100644 index 00000000..94d524fb --- /dev/null +++ b/apps/small_apps/state_sync_test/state_sync_test.tsx @@ -0,0 +1,163 @@ +import React from 'react'; +import springboard from 'springboard'; +import {Module, registerModule} from 'springboard/module_registry/module_registry'; +springboard + +type StateSyncTestState = { + sharedCounter: number; + serverSecretValue: string; + lastUpdated: number; +}; + +/** + * Test module demonstrating the difference between server-only state and shared state. + * + * - sharedCounter: Syncs across all clients in real-time + * - serverSecretValue: Only exists on server, never exposed to clients + * - lastUpdated: Timestamp of last update (shared) + */ +springboard.registerModule('state_sync_test', {}, async (moduleAPI) => { + const sharedState = await moduleAPI.statesAPI.createSharedState('counter', { + value: 0, + lastUpdated: Date.now(), + }); + + const serverState = await moduleAPI.statesAPI.createServerState('secret', { + apiKey: 'super-secret-key-12345', + internalCounter: 0, + }); + + // Actions to manipulate state + const actions = moduleAPI.createActions({ + incrementShared: async () => { + const current = sharedState.getState(); + sharedState.setState({ + value: current.value + 1, + lastUpdated: Date.now(), + }); + }, + + incrementServer: async () => { + const current = serverState.getState(); + serverState.setState({ + ...current, + internalCounter: current.internalCounter + 1, + }); + }, + + getServerValue: async () => { + // This action runs on the server and returns the server-only value + const current = serverState.getState(); + return {internalCounter: current.internalCounter}; + }, + }); + + // UI Component + const StateTestUI: React.FC = () => { + const shared = sharedState.useState(); + const [serverCount, setServerCount] = React.useState(null); + + const fetchServerCount = async () => { + const result = await actions.getServerValue(); + setServerCount(result.internalCounter); + }; + + React.useEffect(() => { + fetchServerCount(); + }, []); + + return ( +
+

State Synchronization Test

+ +
+

✅ Shared State (Syncs to All Clients)

+

Counter Value: {shared.value}

+

Last Updated: {new Date(shared.lastUpdated).toLocaleTimeString()}

+ +
+ +
+

🔒 Server-Only State (Never Syncs to Clients)

+

Internal Counter: {serverCount ?? 'Loading...'}

+

Note: This value is fetched via RPC action, not synced automatically

+
+ + +
+
+ +
+

Testing Instructions

+
    +
  1. Open this page in two browser windows/tabs
  2. +
  3. Click "Increment Shared Counter" in one window - both windows update instantly
  4. +
  5. Click "Increment Server Counter" in one window - only updates when you click "Refresh"
  6. +
  7. Server-only state is never automatically synchronized to clients
  8. +
+
+
+ ); + }; + + moduleAPI.registerRoute('/', {}, () => ); +}); diff --git a/apps/small_apps/tic_tac_toe/tic_tac_toe.tsx b/apps/small_apps/tic_tac_toe/tic_tac_toe.tsx index b49a2353..63dd8d7b 100644 --- a/apps/small_apps/tic_tac_toe/tic_tac_toe.tsx +++ b/apps/small_apps/tic_tac_toe/tic_tac_toe.tsx @@ -53,9 +53,9 @@ const checkForWinner = (board: Board): Winner => { springboard.registerModule('TicTacToe', {}, async (moduleAPI) => { // TODO: springboard docs. if you need to wipe the initial state, you need to rename the state name // or "re-set" it right below for one run of the program - const boardState = await moduleAPI.statesAPI.createPersistentState('board_v5', initialBoard); - const winnerState = await moduleAPI.statesAPI.createPersistentState('winner', null); - const scoreState = await moduleAPI.statesAPI.createPersistentState('score', {X: 0, O: 0, stalemate: 0}); + const boardState = await moduleAPI.statesAPI.createSharedState('board_v5', initialBoard); + const winnerState = await moduleAPI.statesAPI.createSharedState('winner', null); + const scoreState = await moduleAPI.statesAPI.createSharedState('score', {X: 0, O: 0, stalemate: 0}); const actions = moduleAPI.createActions({ clickedCell: async (args: {row: number, column: number}) => { diff --git a/apps/small_apps/tsconfig.json b/apps/small_apps/tsconfig.json new file mode 100644 index 00000000..e7c51f09 --- /dev/null +++ b/apps/small_apps/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "lib": ["ES2019", "DOM", "DOM.Iterable"], + "types": ["node"] + }, + "include": [ + "**/*.ts", + "**/*.tsx" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/packages/jamtools/core/modules/chord_families/chord_families_module.tsx b/packages/jamtools/core/modules/chord_families/chord_families_module.tsx index 4dc21386..1d532379 100644 --- a/packages/jamtools/core/modules/chord_families/chord_families_module.tsx +++ b/packages/jamtools/core/modules/chord_families/chord_families_module.tsx @@ -106,7 +106,7 @@ declare module 'springboard/module_registry/module_registry' { // }); springboard.registerModule('chord_families', {}, async (moduleAPI) => { - const savedData = await moduleAPI.statesAPI.createPersistentState('all_chord_families', []); + const savedData = await moduleAPI.statesAPI.createSharedState('all_chord_families', []); const getChordFamilyHandler = (key: string): ChordFamilyHandler => { const data = savedData.getState()[0]; diff --git a/packages/jamtools/core/modules/macro_module/macro_handlers/inputs/midi_button_input_macro_handler.tsx b/packages/jamtools/core/modules/macro_module/macro_handlers/inputs/midi_button_input_macro_handler.tsx index 1714342d..21130863 100644 --- a/packages/jamtools/core/modules/macro_module/macro_handlers/inputs/midi_button_input_macro_handler.tsx +++ b/packages/jamtools/core/modules/macro_module/macro_handlers/inputs/midi_button_input_macro_handler.tsx @@ -35,7 +35,7 @@ macroTypeRegistry.registerMacroType('midi_button_input', {}, async (macroAPI, co const editing = await macroAPI.statesAPI.createSharedState(getKeyForMacro('editing', fieldName), false); const waitingForConfiguration = await macroAPI.statesAPI.createSharedState(getKeyForMacro('waiting_for_configuration', fieldName), false); const capturedMidiEvent = await macroAPI.statesAPI.createSharedState(getKeyForMacro('captured_midi_event', fieldName), null); - const savedMidiEvents = await macroAPI.statesAPI.createPersistentState(getKeyForMacro('saved_midi_event', fieldName), []); + const savedMidiEvents = await macroAPI.statesAPI.createSharedState(getKeyForMacro('saved_midi_event', fieldName), []); const states: InputMacroStateHolders = { editing, waiting: waitingForConfiguration, diff --git a/packages/jamtools/core/modules/macro_module/macro_handlers/inputs/midi_control_change_input_macro_handler.tsx b/packages/jamtools/core/modules/macro_module/macro_handlers/inputs/midi_control_change_input_macro_handler.tsx index 64bb2419..d6610003 100644 --- a/packages/jamtools/core/modules/macro_module/macro_handlers/inputs/midi_control_change_input_macro_handler.tsx +++ b/packages/jamtools/core/modules/macro_module/macro_handlers/inputs/midi_control_change_input_macro_handler.tsx @@ -25,7 +25,7 @@ macroTypeRegistry.registerMacroType('midi_control_change_input', {}, async (macr const editing = await macroAPI.statesAPI.createSharedState(getKeyForMacro('editing', fieldName), false); const waitingForConfiguration = await macroAPI.statesAPI.createSharedState(getKeyForMacro('waiting_for_configuration', fieldName), false); const capturedMidiEvent = await macroAPI.statesAPI.createSharedState(getKeyForMacro('captured_midi_event', fieldName), null); - const savedMidiEvents = await macroAPI.statesAPI.createPersistentState(getKeyForMacro('saved_midi_event', fieldName), []); + const savedMidiEvents = await macroAPI.statesAPI.createSharedState(getKeyForMacro('saved_midi_event', fieldName), []); const states: InputMacroStateHolders = { editing, waiting: waitingForConfiguration, diff --git a/packages/jamtools/core/modules/macro_module/macro_handlers/inputs/musical_keyboard_input_macro_handler.tsx b/packages/jamtools/core/modules/macro_module/macro_handlers/inputs/musical_keyboard_input_macro_handler.tsx index f5bc4566..54090067 100644 --- a/packages/jamtools/core/modules/macro_module/macro_handlers/inputs/musical_keyboard_input_macro_handler.tsx +++ b/packages/jamtools/core/modules/macro_module/macro_handlers/inputs/musical_keyboard_input_macro_handler.tsx @@ -38,7 +38,7 @@ macroTypeRegistry.registerMacroType( const editing = await macroAPI.statesAPI.createSharedState(getKeyForMacro('editing', fieldName), false); const waitingForConfiguration = await macroAPI.statesAPI.createSharedState(getKeyForMacro('waiting_for_configuration', fieldName), false); const capturedMidiEvent = await macroAPI.statesAPI.createSharedState(getKeyForMacro('captured_midi_event', fieldName), null); - const savedMidiEvents = await macroAPI.statesAPI.createPersistentState(getKeyForMacro('saved_midi_event', fieldName), []); + const savedMidiEvents = await macroAPI.statesAPI.createSharedState(getKeyForMacro('saved_midi_event', fieldName), []); const states: InputMacroStateHolders = { editing, waiting: waitingForConfiguration, diff --git a/packages/jamtools/core/modules/macro_module/macro_handlers/inputs/musical_keyboard_paged_octave_input_macro_handler.tsx b/packages/jamtools/core/modules/macro_module/macro_handlers/inputs/musical_keyboard_paged_octave_input_macro_handler.tsx index 728949a6..ceb6aada 100644 --- a/packages/jamtools/core/modules/macro_module/macro_handlers/inputs/musical_keyboard_paged_octave_input_macro_handler.tsx +++ b/packages/jamtools/core/modules/macro_module/macro_handlers/inputs/musical_keyboard_paged_octave_input_macro_handler.tsx @@ -53,7 +53,7 @@ macroTypeRegistry.registerMacroType( numberOfOctaves: conf.singleOctave ? 1 : initialUserDefinedConfig.numberOfOctaves, }; - const pagedOctaveInputStoredConfig = await macroAPI.statesAPI.createPersistentState(getKeyForMacro('pagedOctaveInputStoredConfig', fieldName), initialUserConfig); + const pagedOctaveInputStoredConfig = await macroAPI.statesAPI.createSharedState(getKeyForMacro('pagedOctaveInputStoredConfig', fieldName), initialUserConfig); const showConfigurationFormState = await macroAPI.statesAPI.createSharedState(getKeyForMacro('pagedOctaveInputShowForm', fieldName), false); diff --git a/packages/jamtools/core/modules/macro_module/macro_handlers/outputs/midi_button_output_macro_handler.tsx b/packages/jamtools/core/modules/macro_module/macro_handlers/outputs/midi_button_output_macro_handler.tsx index 87dd6074..ff2a09e5 100644 --- a/packages/jamtools/core/modules/macro_module/macro_handlers/outputs/midi_button_output_macro_handler.tsx +++ b/packages/jamtools/core/modules/macro_module/macro_handlers/outputs/midi_button_output_macro_handler.tsx @@ -29,7 +29,7 @@ macroTypeRegistry.registerMacroType( (async (macroAPI, inputConf, fieldName) => { const editingState = await macroAPI.statesAPI.createSharedState(getKeyForMacro('editing', fieldName), false); const addingOutputDevice = await macroAPI.statesAPI.createSharedState(getKeyForMacro('adding_output_device', fieldName), {device: null, channel: null}); - const savedOutputDevices = await macroAPI.statesAPI.createPersistentState(getKeyForMacro('saved_output_devices', fieldName), []); + const savedOutputDevices = await macroAPI.statesAPI.createSharedState(getKeyForMacro('saved_output_devices', fieldName), []); const states: OutputMacroStateHolders = { editing: editingState, diff --git a/packages/jamtools/core/modules/macro_module/macro_handlers/outputs/midi_control_change_output_macro_handler.tsx b/packages/jamtools/core/modules/macro_module/macro_handlers/outputs/midi_control_change_output_macro_handler.tsx index 13e8d34c..88772a07 100644 --- a/packages/jamtools/core/modules/macro_module/macro_handlers/outputs/midi_control_change_output_macro_handler.tsx +++ b/packages/jamtools/core/modules/macro_module/macro_handlers/outputs/midi_control_change_output_macro_handler.tsx @@ -30,7 +30,7 @@ macroTypeRegistry.registerMacroType( (async (macroAPI, inputConf, fieldName) => { const editingState = await macroAPI.statesAPI.createSharedState(getKeyForMacro('editing', fieldName), false); const addingOutputDevice = await macroAPI.statesAPI.createSharedState(getKeyForMacro('adding_output_device', fieldName), {device: null, channel: null}); - const savedOutputDevices = await macroAPI.statesAPI.createPersistentState(getKeyForMacro('saved_output_devices', fieldName), []); + const savedOutputDevices = await macroAPI.statesAPI.createSharedState(getKeyForMacro('saved_output_devices', fieldName), []); const states: OutputMacroStateHolders = { editing: editingState, diff --git a/packages/jamtools/core/modules/macro_module/macro_handlers/outputs/musical_keyboard_output_macro_handler.tsx b/packages/jamtools/core/modules/macro_module/macro_handlers/outputs/musical_keyboard_output_macro_handler.tsx index 1a49c47e..218bd575 100644 --- a/packages/jamtools/core/modules/macro_module/macro_handlers/outputs/musical_keyboard_output_macro_handler.tsx +++ b/packages/jamtools/core/modules/macro_module/macro_handlers/outputs/musical_keyboard_output_macro_handler.tsx @@ -24,7 +24,7 @@ macroTypeRegistry.registerMacroType( (async (macroAPI, inputConf, fieldName) => { const editingState = await macroAPI.statesAPI.createSharedState(getKeyForMacro('editing', fieldName), false); const addingOutputDevice = await macroAPI.statesAPI.createSharedState(getKeyForMacro('adding_output_device', fieldName), {device: null, channel: null}); - const savedOutputDevices = await macroAPI.statesAPI.createPersistentState(getKeyForMacro('saved_output_devices', fieldName), []); + const savedOutputDevices = await macroAPI.statesAPI.createSharedState(getKeyForMacro('saved_output_devices', fieldName), []); const states: OutputMacroStateHolders = { editing: editingState, diff --git a/packages/jamtools/core/modules/macro_module/macro_module.tsx b/packages/jamtools/core/modules/macro_module/macro_module.tsx index a8d0f9ed..ece2ecf2 100644 --- a/packages/jamtools/core/modules/macro_module/macro_module.tsx +++ b/packages/jamtools/core/modules/macro_module/macro_module.tsx @@ -148,14 +148,10 @@ export class MacroModule implements Module { return (args: any) => action(args, this.localMode ? {mode: 'local'} : undefined); }, statesAPI: { - createSharedState: (key: string, defaultValue: any) => { + createSharedState: (key: string, defaultValue: State) => { const func = this.localMode ? moduleAPI.statesAPI.createUserAgentState : moduleAPI.statesAPI.createSharedState; return func(key, defaultValue); }, - createPersistentState: (key: string, defaultValue: any) => { - const func = this.localMode ? moduleAPI.statesAPI.createUserAgentState : moduleAPI.statesAPI.createPersistentState; - return func(key, defaultValue); - }, }, createMacro: this.createMacro, isMidiMaestro: () => this.coreDeps.isMaestro() || this.localMode, diff --git a/packages/jamtools/core/modules/macro_module/registered_macro_types.ts b/packages/jamtools/core/modules/macro_module/registered_macro_types.ts index 9ec02dc3..6c475adf 100644 --- a/packages/jamtools/core/modules/macro_module/registered_macro_types.ts +++ b/packages/jamtools/core/modules/macro_module/registered_macro_types.ts @@ -11,7 +11,7 @@ export type RegisterMacroTypeOptions = { export type MacroAPI = { moduleAPI: ModuleAPI; midiIO: IoModule; - statesAPI: Pick; + statesAPI: Pick; createAction: ModuleAPI['createAction']; isMidiMaestro: () => boolean; onDestroy: (cb: () => void) => void; diff --git a/packages/jamtools/features/modules/dashboards/keytar_and_foot_dashboard/multi_octave_supervisor.tsx b/packages/jamtools/features/modules/dashboards/keytar_and_foot_dashboard/multi_octave_supervisor.tsx index 42348d62..43ed3bb2 100644 --- a/packages/jamtools/features/modules/dashboards/keytar_and_foot_dashboard/multi_octave_supervisor.tsx +++ b/packages/jamtools/features/modules/dashboards/keytar_and_foot_dashboard/multi_octave_supervisor.tsx @@ -151,7 +151,7 @@ export class MultiOctaveSupervisor { debugSavedInputEvent, debugMidiState, ] = await Promise.all([ - this.moduleAPI.statesAPI.createPersistentState(makeStateName('enableDebugging'), true), + this.moduleAPI.statesAPI.createSharedState(makeStateName('enableDebugging'), true), this.moduleAPI.statesAPI.createSharedState(makeStateName('debugSavedInputEvent'), null), this.moduleAPI.statesAPI.createSharedState(makeStateName('debugMidiState'), this.midiState), ]); diff --git a/packages/jamtools/features/modules/dashboards/keytar_and_foot_dashboard/single_octave_root_mode_supervisor.tsx b/packages/jamtools/features/modules/dashboards/keytar_and_foot_dashboard/single_octave_root_mode_supervisor.tsx index 6ba9df04..d309b09a 100644 --- a/packages/jamtools/features/modules/dashboards/keytar_and_foot_dashboard/single_octave_root_mode_supervisor.tsx +++ b/packages/jamtools/features/modules/dashboards/keytar_and_foot_dashboard/single_octave_root_mode_supervisor.tsx @@ -371,7 +371,7 @@ export class SingleOctaveRootModeSupervisor { debugSavedInputEvent, debugMidiState, ] = await Promise.all([ - this.moduleAPI.statesAPI.createPersistentState(makeStateName('enableDebugging'), true), + this.moduleAPI.statesAPI.createSharedState(makeStateName('enableDebugging'), true), this.moduleAPI.statesAPI.createSharedState(makeStateName('debugSavedInputEvent'), null), this.moduleAPI.statesAPI.createSharedState(makeStateName('debugMidiState'), this.midiState), ]); diff --git a/packages/jamtools/features/modules/eventide/eventide_module.tsx b/packages/jamtools/features/modules/eventide/eventide_module.tsx index 666c4eb2..ed7aa518 100644 --- a/packages/jamtools/features/modules/eventide/eventide_module.tsx +++ b/packages/jamtools/features/modules/eventide/eventide_module.tsx @@ -14,8 +14,8 @@ type EventidePresetState = { } springbord.registerModule('Eventide', {}, async (moduleAPI) => { - const currentPresetState = await moduleAPI.statesAPI.createPersistentState('currentPresetState', null); - const favoritedPresetsState = await moduleAPI.statesAPI.createPersistentState('favoritedPresets', []); + const currentPresetState = await moduleAPI.statesAPI.createSharedState('currentPresetState', null); + const favoritedPresetsState = await moduleAPI.statesAPI.createSharedState('favoritedPresets', []); const macroModule = moduleAPI.deps.module.moduleRegistry.getModule('macro'); const eventideMacro = await macroModule.createMacro(moduleAPI, 'eventide_pedal', 'musical_keyboard_output', {}); diff --git a/packages/jamtools/features/modules/midi_playback/midi_playback_module.tsx b/packages/jamtools/features/modules/midi_playback/midi_playback_module.tsx index 4a9799f3..6a625e96 100644 --- a/packages/jamtools/features/modules/midi_playback/midi_playback_module.tsx +++ b/packages/jamtools/features/modules/midi_playback/midi_playback_module.tsx @@ -19,7 +19,7 @@ type MidiPlaybackModuleReturnValue = { springboard.registerModule('MidiPlayback', {}, async (moduleAPI): Promise => { const midiFileModule = moduleAPI.deps.module.moduleRegistry.getModule('MidiFile'); - const savedMidiFileData = await moduleAPI.statesAPI.createPersistentState('savedMidiFileData', null); + const savedMidiFileData = await moduleAPI.statesAPI.createSharedState('savedMidiFileData', null); const outputDevice = await moduleAPI.deps.module.moduleRegistry.getModule('macro').createMacro(moduleAPI, 'outputDevice', 'musical_keyboard_output', {}); diff --git a/packages/jamtools/features/modules/ultimate_guitar/ultimate_guitar_module.tsx b/packages/jamtools/features/modules/ultimate_guitar/ultimate_guitar_module.tsx index b4775264..932aead8 100644 --- a/packages/jamtools/features/modules/ultimate_guitar/ultimate_guitar_module.tsx +++ b/packages/jamtools/features/modules/ultimate_guitar/ultimate_guitar_module.tsx @@ -87,9 +87,9 @@ class States { savedTabs, currentSetlistStatus, ] = await Promise.all([ - this.moduleAPI.statesAPI.createPersistentState('saved_setlists', []), - this.moduleAPI.statesAPI.createPersistentState('saved_tabs', []), - this.moduleAPI.statesAPI.createPersistentState('current_setlist_status', null), + this.moduleAPI.statesAPI.createSharedState('saved_setlists', []), + this.moduleAPI.statesAPI.createSharedState('saved_tabs', []), + this.moduleAPI.statesAPI.createSharedState('current_setlist_status', null), ]); this.savedSetlists = savedSetlists; diff --git a/packages/springboard/cli/package.json b/packages/springboard/cli/package.json index 68ff4014..d6f45394 100644 --- a/packages/springboard/cli/package.json +++ b/packages/springboard/cli/package.json @@ -25,7 +25,7 @@ "build-empty-jamtools-app-all": "npx tsx src/cli.ts build ../../../apps/small_apps/empty_jamtools_app/index.ts --platforms all", "build-partykit": "ESBUILD_OUT_DIR=../platforms/partykit/dist npx tsx src/cli.ts build ../../../apps/small_apps/handraiser/index.ts --platforms partykit", "dev": "npm run build -- --watch", - "dev-dev": "npx tsx src/cli.ts dev ../../../apps/small_apps/handraiser/index.ts", + "dev-dev": "npx tsx src/cli.ts dev ../../../apps/jamtools/modules/index.ts", "clean": "rm -rf dist", "add-header": "./scripts/add-node-executable-header.sh", "cli-docs": "npx tsx scripts/make_cli_docs.ts", @@ -48,6 +48,12 @@ "author": "", "license": "ISC", "devDependencies": { + "@babel/generator": "^7.28.5", + "@babel/parser": "^7.28.5", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@types/babel__generator": "^7.27.0", + "@types/babel__traverse": "^7.28.0" }, "description": "" } diff --git a/packages/springboard/cli/src/build.ts b/packages/springboard/cli/src/build.ts index 3244eef2..a164989f 100644 --- a/packages/springboard/cli/src/build.ts +++ b/packages/springboard/cli/src/build.ts @@ -75,6 +75,14 @@ export const platformBrowserBuildConfig: BuildConfig = { export const platformOfflineBrowserBuildConfig: BuildConfig = { ...platformBrowserBuildConfig, platformEntrypoint: () => '@springboardjs/platforms-browser/entrypoints/offline_entrypoint.ts', + esbuildPlugins: (args) => [ + esbuildPluginPlatformInject('browser', {preserveServerStatesAndActions: true}), + esbuildPluginHtmlGenerate( + args.outDir, + `${args.nodeModulesParentDir}/node_modules/@springboardjs/platforms-browser/index.html`, + args.documentMeta, + ), + ], }; export const platformNodeBuildConfig: BuildConfig = { diff --git a/packages/springboard/cli/src/esbuild_plugins/esbuild_plugin_platform_inject.test.ts b/packages/springboard/cli/src/esbuild_plugins/esbuild_plugin_platform_inject.test.ts new file mode 100644 index 00000000..fa4cf0ff --- /dev/null +++ b/packages/springboard/cli/src/esbuild_plugins/esbuild_plugin_platform_inject.test.ts @@ -0,0 +1,127 @@ +import {describe, it, expect, beforeAll} from 'vitest'; +import path from 'path'; +import fs from 'fs'; +import {execSync} from 'child_process'; + +describe('esbuild_plugin_platform_inject', () => { + const rootDir = path.resolve(__dirname, '../../../../..'); + const cliPath = path.resolve(__dirname, '../cli.ts'); + const testAppPath = 'server_state_edge_cases/server_state_edge_cases.tsx'; + const distPath = path.resolve(rootDir, 'apps/small_apps/dist'); + + beforeAll(() => { + // Clean dist directory before running tests + if (fs.existsSync(distPath)) { + fs.rmSync(distPath, { recursive: true, force: true }); + } + }); + + it('should remove server states and strip action bodies in browser build', async () => { + // Build the edge cases app using the CLI + execSync(`npx tsx ${cliPath} build ${testAppPath}`, { + cwd: path.resolve(rootDir, 'apps/small_apps'), + stdio: 'inherit', + }); + + // Read the browser build output + const browserDistPath = path.join(distPath, 'browser/dist'); + const jsFiles = fs.readdirSync(browserDistPath).filter(f => f.endsWith('.js') && f.startsWith('index-')); + expect(jsFiles.length).toBeGreaterThan(0); + + const browserBuildContent = fs.readFileSync(path.join(browserDistPath, jsFiles[0]), 'utf-8'); + + // Verify server state CALLS are removed (secret data never appears) + expect(browserBuildContent).not.toContain('user-123'); // From serverStates.userSession + expect(browserBuildContent).not.toContain('secret-token'); + expect(browserBuildContent).not.toContain('sk_test_123'); // From serverStates.apiKeys + expect(browserBuildContent).not.toContain('super-secret-password'); // From singleServerState + expect(browserBuildContent).not.toContain('admin-key-123'); + + // Verify regular actions are NOT stripped (they should have full bodies) + expect(browserBuildContent).toContain('createAction("regular1"'); + expect(browserBuildContent).toContain('Regular action - will be stripped in browser'); + expect(browserBuildContent).toContain('regularActions'); + expect(browserBuildContent).toContain('Regular action that will be stripped'); + + // Verify server action calls exist but bodies are empty + expect(browserBuildContent).toContain('createServerAction("serverAction1"'); + expect(browserBuildContent).toContain('createServerAction("serverAction2"'); + expect(browserBuildContent).toContain('createServerAction("serverAction3"'); + + // Verify server action bodies are stripped (should not contain implementation details) + expect(browserBuildContent).not.toContain('This should be removed from client'); + // Note: In non-minified builds, myHandler function declaration still exists as dead code + // In production builds with minification, it would be tree-shaken away + // The important thing is that the server secrets aren't exposed + expect(browserBuildContent).toContain('Variable handler'); // Dead code in dev builds + + // Verify createServerActions bodies are empty + expect(browserBuildContent).toContain('serverActions'); + expect(browserBuildContent).not.toContain('Authenticating user:'); + expect(browserBuildContent).not.toContain('Authorizing with keys'); + + }, 60000); + + it('should keep server states and full action bodies in server build', async () => { + // The server build was already created in the previous test + // Read the server build output (not node - the server build is where everything is bundled) + const serverDistPath = path.join(distPath, 'server/dist'); + const jsFiles = fs.readdirSync(serverDistPath).filter(f => f.endsWith('.cjs')); + expect(jsFiles.length).toBeGreaterThan(0); + + const nodeBuildContent = fs.readFileSync(path.join(serverDistPath, jsFiles[0]), 'utf-8'); + + // Verify server states are present + expect(nodeBuildContent).toContain('createServerStates'); + expect(nodeBuildContent).toContain('userSession'); + expect(nodeBuildContent).toContain('apiKeys'); + expect(nodeBuildContent).toContain('user-123'); + expect(nodeBuildContent).toContain('secret-token'); + expect(nodeBuildContent).toContain('sk_test_123'); + + // Verify createServerState is present + expect(nodeBuildContent).toContain('createServerState'); + expect(nodeBuildContent).toContain('super-secret-password'); + expect(nodeBuildContent).toContain('admin-key-123'); + + // Verify action bodies are intact + expect(nodeBuildContent).toContain('Regular action - will be stripped in browser'); + expect(nodeBuildContent).toContain('This should be removed from client'); + expect(nodeBuildContent).toContain('Variable handler'); + expect(nodeBuildContent).toContain('Regular action that will be stripped'); + + // Verify server action bodies are intact + expect(nodeBuildContent).toContain('Authenticating user:'); + expect(nodeBuildContent).toContain('Authorizing with keys'); + expect(nodeBuildContent).toContain('hasPassword: !!config.dbPassword'); + + }, 60000); + + it('should only strip createServerAction and createServerActions, not regular actions', async () => { + // Use the browser build from the first test + const browserDistPath = path.join(distPath, 'browser/dist'); + const jsFiles = fs.readdirSync(browserDistPath).filter(f => f.endsWith('.js') && f.startsWith('index-')); + const browserBuildContent = fs.readFileSync(path.join(browserDistPath, jsFiles[0]), 'utf-8'); + + // Verify regular createAction keeps its body + expect(browserBuildContent).toContain('createAction("regular1"'); + expect(browserBuildContent).toContain('Regular action - will be stripped in browser'); + + // Verify regular createActions keeps its bodies + expect(browserBuildContent).toContain('regularActions'); + expect(browserBuildContent).toContain('Regular action that will be stripped'); + + // Verify createServerAction bodies are stripped + expect(browserBuildContent).toContain('createServerAction("serverAction1"'); + expect(browserBuildContent).toContain('createServerAction("serverAction2"'); + expect(browserBuildContent).toContain('createServerAction("serverAction3"'); + expect(browserBuildContent).not.toContain('This should be removed from client'); + expect(browserBuildContent).not.toContain('Handler for test called'); + // Variable handler will still exist as an unused function declaration + expect(browserBuildContent).toContain('Variable handler'); + + // Verify createServerActions bodies are stripped + expect(browserBuildContent).toContain('serverActions'); + expect(browserBuildContent).not.toContain('Authenticating user:'); + }, 60000); +}); diff --git a/packages/springboard/cli/src/esbuild_plugins/esbuild_plugin_platform_inject.ts b/packages/springboard/cli/src/esbuild_plugins/esbuild_plugin_platform_inject.ts index d2a6986f..e2e21b09 100644 --- a/packages/springboard/cli/src/esbuild_plugins/esbuild_plugin_platform_inject.ts +++ b/packages/springboard/cli/src/esbuild_plugins/esbuild_plugin_platform_inject.ts @@ -1,29 +1,147 @@ import fs from 'fs'; +import * as parser from '@babel/parser'; +import traverse, {NodePath} from '@babel/traverse'; +import generate from '@babel/generator'; import type {Plugin} from 'esbuild'; +import type * as t from '@babel/types'; -export const esbuildPluginPlatformInject = (platform: 'node' | 'browser' | 'fetch' | 'react-native'): Plugin => { - return { - name: 'platform-macro', - setup(build) { - build.onLoad({ filter: /\.tsx?$/ }, async (args) => { - let source = await fs.promises.readFile(args.path, 'utf8'); - - // Replace platform-specific blocks based on the platform - const platformRegex = new RegExp(`\/\/ @platform "${platform}"([\\s\\S]*?)\/\/ @platform end`, 'g'); - const otherPlatformRegex = new RegExp(`\/\/ @platform "(node|browser|react-native|fetch)"([\\s\\S]*?)\/\/ @platform end`, 'g'); - - // Include only the code relevant to the current platform - source = source.replace(platformRegex, '$1'); - - // Remove the code for the other platforms - source = source.replace(otherPlatformRegex, ''); - - return { - contents: source, - loader: args.path.split('.').pop() as 'js', - }; - }); - }, - }; +export const esbuildPluginPlatformInject = ( + platform: 'node' | 'browser' | 'fetch' | 'react-native', + options?: {preserveServerStatesAndActions?: boolean} +): Plugin => { + const preserveServerStatesAndActions = options?.preserveServerStatesAndActions || false; + + return { + name: 'platform-macro', + setup(build) { + build.onLoad({filter: /\.tsx?$/}, async (args) => { + let source = await fs.promises.readFile(args.path, 'utf8'); + + // Early return if file doesn't need any transformations + const hasPlatformAnnotations = /@platform "(node|browser|react-native|fetch)"/.test(source); + const hasServerCalls = preserveServerStatesAndActions && /createServer(State|States|Action|Actions)/.test(source); + + if (!hasPlatformAnnotations && !hasServerCalls) { + return { + contents: source, + loader: args.path.split('.').pop() as 'js', + }; + } + + // Then, replace platform-specific blocks based on the platform + const platformRegex = new RegExp(`\/\/ @platform "${platform}"([\\s\\S]*?)\/\/ @platform end`, 'g'); + const otherPlatformRegex = new RegExp(`\/\/ @platform "(node|browser|react-native|fetch)"([\\s\\S]*?)\/\/ @platform end`, 'g'); + + // Include only the code relevant to the current platform + source = source.replace(platformRegex, '$1'); + + // Remove the code for the other platforms + source = source.replace(otherPlatformRegex, ''); + + if ((platform === 'browser' || platform === 'react-native') && !preserveServerStatesAndActions) { + const hasServerCalls = /createServer(State|States|Action|Actions)/.test(source); + if (hasServerCalls) { + try { + const ast = parser.parse(source, { + sourceType: 'module', + plugins: ['typescript', 'jsx'], + }); + + const nodesToRemove: NodePath[] = []; + + traverse(ast, { + VariableDeclaration(path) { + const declaration = path.node.declarations[0]; + if (!declaration || !declaration.init) return; + + // Handle await expressions + let callExpr = declaration.init; + if (callExpr.type === 'AwaitExpression') { + callExpr = callExpr.argument; + } + + if (callExpr.type !== 'CallExpression') return; + if (callExpr.callee.type !== 'MemberExpression') return; + if (callExpr.callee.property.type !== 'Identifier') return; + + const methodName = callExpr.callee.property.name; + + // Remove entire variable declarations for createServerState/createServerStates + if (methodName === 'createServerState' || methodName === 'createServerStates') { + const object = callExpr.callee.object; + if ( + object.type === 'MemberExpression' && + object.property.type === 'Identifier' && + object.property.name === 'statesAPI' + ) { + nodesToRemove.push(path); + } + } + + // For createServerAction/createServerActions, strip function bodies + if (methodName === 'createServerAction' || methodName === 'createServerActions') { + + // For createServerAction (singular), handle the pattern: createServerAction(key, config, handler) + // The handler is the 3rd argument (index 2) or could be in the 2nd if config is omitted + if (methodName === 'createServerAction') { + // Find the last argument (the handler function) + const lastArgIndex = callExpr.arguments.length - 1; + if (lastArgIndex >= 0) { + // Replace whatever expression with an empty arrow function + callExpr.arguments[lastArgIndex] = { + type: 'ArrowFunctionExpression', + params: [], + body: { + type: 'BlockStatement', + body: [], + directives: [], + }, + async: true, + } as any; + } + } + + // For createServerActions (plural), first argument should be an object with action definitions + if (methodName === 'createServerActions') { + const firstArg = callExpr.arguments[0]; + if (firstArg && firstArg.type === 'ObjectExpression') { + firstArg.properties.forEach((prop: any) => { + if (prop.type === 'ObjectProperty' && prop.value) { + // Replace function bodies with empty arrow functions + if (prop.value.type === 'ArrowFunctionExpression' || prop.value.type === 'FunctionExpression') { + prop.value.body = { + type: 'BlockStatement', + body: [], + directives: [], + }; + } + } + }); + } + } + } + }, + }); + + // Remove nodes in reverse order to avoid index shifting + nodesToRemove.reverse().forEach(path => path.remove()); + + // Generate the modified source + const output = generate(ast, {}, source); + source = output.code; + } catch (err) { + // If AST parsing fails, log warning but continue with original source + console.warn(`Failed to parse ${args.path} for server state/action removal:`, err); + } + } + } + + return { + contents: source, + loader: args.path.split('.').pop() as 'js', + }; + }); + }, + }; } diff --git a/packages/springboard/core/engine/module_api.ts b/packages/springboard/core/engine/module_api.ts index b2d24883..eb4d44d2 100644 --- a/packages/springboard/core/engine/module_api.ts +++ b/packages/springboard/core/engine/module_api.ts @@ -1,4 +1,4 @@ -import {SharedStateSupervisor, StateSupervisor, UserAgentStateSupervisor} from '../services/states/shared_state_service'; +import {ServerStateSupervisor, SharedStateSupervisor, StateSupervisor, UserAgentStateSupervisor} from '../services/states/shared_state_service'; import {ExtraModuleDependencies, Module, NavigationItemConfig, RegisteredRoute} from 'springboard/module_registry/module_registry'; import {CoreDependencies, ModuleDependencies} from '../types/module_types'; import {RegisterRouteOptions} from './register'; @@ -104,11 +104,51 @@ export class ModuleAPI { this.module.applicationShell = component; }; - createStates = async >(states: States): Promise<{[K in keyof States]: StateSupervisor}> => { + createSharedStates = async >(states: States): Promise<{[K in keyof States]: StateSupervisor}> => { const keys = Object.keys(states); const promises = keys.map(async key => { return { - state: await this.statesAPI.createPersistentState(key, states[key]), + state: await this.statesAPI.createSharedState(key, states[key]), + key, + }; + }); + + const result = {} as {[K in keyof States]: StateSupervisor}; + + const supervisors = await Promise.all(promises); + for (const key of keys) { + (result[key] as StateSupervisor) = supervisors.find(s => s.key === key as any)!.state; + } + + return result; + }; + + createStates = this.createSharedStates; + + createServerStates = async >(states: States): Promise<{[K in keyof States]: StateSupervisor}> => { + const keys = Object.keys(states); + const promises = keys.map(async key => { + return { + state: await this.statesAPI.createServerState(key, states[key]), + key, + }; + }); + + const result = {} as {[K in keyof States]: StateSupervisor}; + + const supervisors = await Promise.all(promises); + for (const key of keys) { + (result[key] as StateSupervisor) = supervisors.find(s => s.key === key as any)!.state; + } + + return result; + }; + + createUserAgentStates = async >(states: States): Promise<{[K in keyof States]: StateSupervisor}> => { + const keys = Object.keys(states); + const promises = keys.map(async key => { + return { + state: await this.statesAPI.createUserAgentState(key, states[key]), key, }; }); @@ -135,6 +175,32 @@ export class ModuleAPI { return actions; }; + /** + * Create a server-only action that runs exclusively on the server. + * In client builds, the implementation will be stripped out, leaving only the RPC call structure. + */ + createServerAction = < + Options extends ActionConfigOptions, + Args extends undefined | object, + ReturnValue extends Promise + >( + actionName: string, + options: Options, + cb: undefined extends Args ? ActionCallback : ActionCallback + ): undefined extends Args ? ((args?: Args, options?: ActionCallOptions) => ReturnValue) : ((args: Args, options?: ActionCallOptions) => ReturnValue) => { + return this.createAction(actionName, options, cb); + }; + + /** + * Create multiple server-only actions that run exclusively on the server. + * In client builds, the implementations will be stripped out, leaving only the RPC call structure. + */ + createServerActions = >>( + actions: Actions + ): { [K in keyof Actions]: undefined extends Parameters[0] ? ((payload?: Parameters[0], options?: ActionCallOptions) => Promise>) : ((payload: Parameters[0], options?: ActionCallOptions) => Promise>) } => { + return this.createActions(actions); + }; + setRpcMode = (mode: 'remote' | 'local') => { this.options.rpcMode = mode; }; @@ -217,19 +283,12 @@ export class StatesAPI { } /** - * Create a piece of state to be shared between all connected devices. This state should generally be treated as ephemeral, though it will be cached on the server to retain application state. + * Create a piece of state to be saved in persistent storage such as a database or localStorage. + * If the deployment is multi-player, then this data is shared between all connected devices. + * This is the primary method for creating shared state that persists and syncs across clients. */ public createSharedState = async (stateName: string, initialValue: State): Promise> => { const fullKey = `${this.prefix}|state.shared|${stateName}`; - const supervisor = new SharedStateSupervisor(fullKey, initialValue, this.modDeps.services.remoteSharedStateService); - return supervisor; - }; - - /** - * Create a piece of state to be saved in persistent storage such as a database or localStorage. If the deployment is multi-player, then this data is shared between all connected devices. - */ - public createPersistentState = async (stateName: string, initialValue: State): Promise> => { - const fullKey = `${this.prefix}|state.persistent|${stateName}`; const cachedValue = this.modDeps.services.remoteSharedStateService.getCachedValue(fullKey) as State | undefined; if (cachedValue !== undefined) { @@ -245,10 +304,6 @@ export class StatesAPI { const supervisor = new SharedStateSupervisor(fullKey, initialValue, this.modDeps.services.remoteSharedStateService); - // TODO: unsubscribe through onDestroy lifecycle of StatesAPI - // this createPersistentState function is not Maestro friendly - // every time you access coreDeps, that's the case - // persistent state has been a weird thing const sub = supervisor.subjectForKVStorePublish.subscribe(async value => { await this.coreDeps.storage.remote.set(fullKey, value); }); @@ -257,6 +312,11 @@ export class StatesAPI { return supervisor; }; + /** + * @deprecated Use createSharedState instead. This is an alias for backwards compatibility. + */ + public createPersistentState = this.createSharedState; + /** * Create a piece of state to be saved on the given user agent. In the browser's case, this will use `localStorage` */ @@ -283,4 +343,54 @@ export class StatesAPI { return supervisor; }; + + /** + * Create a piece of server-only state that is saved in persistent storage but is NOT synced to clients. + * This is useful for sensitive server-side data that should never be exposed to the client. + * The state is still persisted to the remote storage (database/etc), but changes are not broadcast via RPC. + */ + public createServerState = async (stateName: string, initialValue: State): Promise> => { + const fullKey = `${this.prefix}|state.server|${stateName}`; + + // Load from persistent storage if available + const storedValue = await this.coreDeps.storage.remote.get(fullKey); + if (storedValue !== null && storedValue !== undefined) { + initialValue = storedValue; + } else if (this.coreDeps.isMaestro()) { + await this.coreDeps.storage.remote.set(fullKey, initialValue); + } + + const supervisor = new ServerStateSupervisor(fullKey, initialValue); + + // Subscribe to persist changes to storage, but do NOT broadcast to clients + const sub = supervisor.subjectForKVStorePublish.subscribe(async value => { + await this.coreDeps.storage.remote.set(fullKey, value); + }); + this.onDestroy(sub.unsubscribe); + + return supervisor; + }; + + /** + * Create multiple server-only states at once. Convenience method for batch creation. + * Each state is saved in persistent storage but is NOT synced to clients. + */ + public createServerStates = async >(states: States): Promise<{[K in keyof States]: StateSupervisor}> => { + const keys = Object.keys(states); + const promises = keys.map(async key => { + return { + state: await this.createServerState(key, states[key]), + key, + }; + }); + + const result = {} as {[K in keyof States]: StateSupervisor}; + + const supervisors = await Promise.all(promises); + for (const key of keys) { + (result[key] as StateSupervisor) = supervisors.find(s => s.key === key as any)!.state; + } + + return result; + }; } diff --git a/packages/springboard/core/modules/files/files_module.tsx b/packages/springboard/core/modules/files/files_module.tsx index 6d4f912d..5d175a99 100644 --- a/packages/springboard/core/modules/files/files_module.tsx +++ b/packages/springboard/core/modules/files/files_module.tsx @@ -41,7 +41,7 @@ type FilesModule = { } springboard.registerModule('Files', {}, async (moduleAPI): Promise => { - const allStoredFiles = await moduleAPI.statesAPI.createPersistentState('allStoredFiles', []); + const allStoredFiles = await moduleAPI.statesAPI.createSharedState('allStoredFiles', []); const fileUploader = new IndexedDbFileStorageProvider(); await fileUploader.initialize(); diff --git a/packages/springboard/core/services/states/shared_state_service.ts b/packages/springboard/core/services/states/shared_state_service.ts index 8c96e0c5..994b51d6 100644 --- a/packages/springboard/core/services/states/shared_state_service.ts +++ b/packages/springboard/core/services/states/shared_state_service.ts @@ -203,3 +203,44 @@ export class SharedStateSupervisor implements StateSupervisor { return useSubject(this.getState(), this.subject)!; }; } + +/** + * Server-only state supervisor that persists to storage but does NOT sync to clients. + * This is useful for server-side data that should never be exposed to the client. + */ +export class ServerStateSupervisor implements StateSupervisor { + public subject: Subject = new Subject(); + public subjectForKVStorePublish: Subject = new Subject(); + private currentValue: State; + + constructor(private key: string, initialValue: State) { + this.currentValue = initialValue; + } + + public getState = (): State => { + return this.currentValue; + }; + + public setState = (state: State | StateCallback): State => { + if (typeof state === 'function') { + const cb = state as StateCallback; + const result = cb(this.getState()); + return this.setState(result); + } + + this.currentValue = state; + this.subject.next(state); + this.subjectForKVStorePublish.next(state); + // NOTE: Deliberately does NOT call sendRpcSetSharedState - this is server-only state + return state; + }; + + public setStateImmer = (immerCallback: StateCallbackImmer): State => { + const result = produce(this.getState(), immerCallback); + return this.setState(result); + }; + + public useState = (): State => { + return useSubject(this.getState(), this.subject)!; + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0d68f62d..ea4384d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -97,7 +97,7 @@ importers: version: 5.62.0(eslint@8.57.1)(typescript@5.8.3) '@vitejs/plugin-react': specifier: ^4.3.1 - version: 4.4.1(vite@5.4.19(@types/node@22.15.21)) + version: 4.4.1(vite@5.4.19(@types/node@24.10.0)) eslint: specifier: ^8.39.0 version: 8.57.1 @@ -118,7 +118,7 @@ importers: version: link:packages/springboard/server tsup: specifier: ^8.3.5 - version: 8.5.0(postcss@8.5.3)(tsx@4.19.4)(typescript@5.8.3) + version: 8.5.0(jiti@2.6.1)(postcss@8.5.3)(tsx@4.19.4)(typescript@5.8.3) tsx: specifier: ^4.19.2 version: 4.19.4 @@ -127,13 +127,13 @@ importers: version: 5.8.3 vite: specifier: ^5.4.6 - version: 5.4.19(@types/node@22.15.21) + version: 5.4.19(@types/node@24.10.0) vite-tsconfig-paths: specifier: ^5.0.1 - version: 5.1.4(typescript@5.8.3)(vite@5.4.19(@types/node@22.15.21)) + version: 5.1.4(typescript@5.8.3)(vite@5.4.19(@types/node@24.10.0)) vitest: specifier: ^2.1.1 - version: 2.1.9(@types/node@22.15.21)(jsdom@25.0.1) + version: 2.1.9(@types/node@24.10.0)(jsdom@25.0.1) apps/jamtools: dependencies: @@ -177,6 +177,9 @@ importers: '@springboardjs/platforms-node': specifier: workspace:* version: link:../../packages/springboard/platforms/node + better-sqlite3: + specifier: ^11.3.0 + version: 11.10.0 react: specifier: ^19.1.0 version: 19.1.0 @@ -223,7 +226,7 @@ importers: version: 5.62.0(eslint@8.57.1)(typescript@5.8.3) '@vitejs/plugin-react': specifier: ^4.3.1 - version: 4.4.1(vite@5.4.19(@types/node@22.15.21)) + version: 4.4.1(vite@5.4.19(@types/node@24.10.0)) eslint: specifier: ^8.39.0 version: 8.57.1 @@ -238,19 +241,19 @@ importers: version: 1.12.0(@testing-library/dom@10.4.0) tsup: specifier: ^8.3.5 - version: 8.5.0(postcss@8.5.3)(tsx@4.19.4)(typescript@5.8.3) + version: 8.5.0(jiti@2.6.1)(postcss@8.5.3)(tsx@4.19.4)(typescript@5.8.3) typescript: specifier: ^5.4.5 version: 5.8.3 vite: specifier: ^5.4.6 - version: 5.4.19(@types/node@22.15.21) + version: 5.4.19(@types/node@24.10.0) vite-tsconfig-paths: specifier: ^5.0.1 - version: 5.1.4(typescript@5.8.3)(vite@5.4.19(@types/node@22.15.21)) + version: 5.1.4(typescript@5.8.3)(vite@5.4.19(@types/node@24.10.0)) vitest: specifier: ^2.1.1 - version: 2.1.9(@types/node@22.15.21)(jsdom@25.0.1) + version: 2.1.9(@types/node@24.10.0)(jsdom@25.0.1) packages/jamtools/core: dependencies: @@ -391,6 +394,25 @@ importers: typescript: specifier: ^5.4.5 version: 5.8.3 + devDependencies: + '@babel/generator': + specifier: ^7.28.5 + version: 7.28.5 + '@babel/parser': + specifier: ^7.28.5 + version: 7.28.5 + '@babel/traverse': + specifier: ^7.28.5 + version: 7.28.5 + '@babel/types': + specifier: ^7.28.5 + version: 7.28.5 + '@types/babel__generator': + specifier: ^7.27.0 + version: 7.27.0 + '@types/babel__traverse': + specifier: ^7.28.0 + version: 7.28.0 packages/springboard/core: dependencies: @@ -656,7 +678,7 @@ importers: version: link:../../core svelte-preprocess: specifier: ^6.0.3 - version: 6.0.3(@babel/core@7.27.1)(postcss-load-config@6.0.1(postcss@8.5.3)(tsx@4.19.4))(postcss@8.5.3)(svelte@5.34.7)(typescript@5.8.3) + version: 6.0.3(@babel/core@7.27.1)(postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.3)(tsx@4.19.4))(postcss@8.5.3)(svelte@5.34.7)(typescript@5.8.3) devDependencies: '@jamtools/core': specifier: workspace:* @@ -740,14 +762,18 @@ packages: resolution: {integrity: sha512-IaaGWsQqfsQWVLqMn9OB92MNN7zukfVA4s7KKAI0KfrrDsZ0yhi5uV4baBuLuN7n3vsZpwP8asPPcVwApxvjBQ==} engines: {node: '>=6.9.0'} - '@babel/generator@7.27.1': - resolution: {integrity: sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w==} + '@babel/generator@7.28.5': + resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} engines: {node: '>=6.9.0'} '@babel/helper-compilation-targets@7.27.2': resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} engines: {node: '>=6.9.0'} + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.27.1': resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} engines: {node: '>=6.9.0'} @@ -770,6 +796,10 @@ packages: resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.27.1': resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} @@ -783,6 +813,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.28.5': + resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-transform-react-jsx-self@7.27.1': resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} engines: {node: '>=6.9.0'} @@ -807,10 +842,18 @@ packages: resolution: {integrity: sha512-ZCYtZciz1IWJB4U61UPu4KEaqyfj+r5T1Q5mqPo+IBpcG9kHv30Z0aD8LXPgC1trYa6rK0orRyAhqUgk4MjmEg==} engines: {node: '>=6.9.0'} + '@babel/traverse@7.28.5': + resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==} + engines: {node: '>=6.9.0'} + '@babel/types@7.27.1': resolution: {integrity: sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==} engines: {node: '>=6.9.0'} + '@babel/types@7.28.5': + resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} + engines: {node: '>=6.9.0'} + '@cloudflare/workerd-darwin-64@1.20240718.0': resolution: {integrity: sha512-BsPZcSCgoGnufog2GIgdPuiKicYTNyO/Dp++HbpLRH+yQdX3x4aWx83M+a0suTl1xv76dO4g9aw7SIB6OSgIyQ==} engines: {node: '>=16'} @@ -1164,6 +1207,9 @@ packages: resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + '@jridgewell/gen-mapping@0.3.8': resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==} engines: {node: '>=6.0.0'} @@ -1182,6 +1228,9 @@ packages: '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} @@ -1674,6 +1723,9 @@ packages: '@types/babel__traverse@7.20.7': resolution: {integrity: sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==} + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/better-sqlite3@7.6.13': resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} @@ -1704,8 +1756,8 @@ packages: '@types/node@22.15.19': resolution: {integrity: sha512-3vMNr4TzNQyjHcRZadojpRaD9Ofr6LsonZAoQ+HMUa/9ORTPoxVIw0e0mpqWpdjj8xybyCM+oKOUH2vwFu/oEw==} - '@types/node@22.15.21': - resolution: {integrity: sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ==} + '@types/node@24.10.0': + resolution: {integrity: sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==} '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} @@ -2218,6 +2270,15 @@ packages: supports-color: optional: true + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + decamelize@1.2.0: resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} engines: {node: '>=0.10.0'} @@ -2964,6 +3025,10 @@ packages: resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -3766,6 +3831,7 @@ packages: source-map@0.8.0-beta.0: resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} engines: {node: '>= 8'} + deprecated: The work that was done in this beta branch won't be included in future versions stack-utils@2.0.6: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} @@ -4124,6 +4190,9 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici@5.29.0: resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==} engines: {node: '>=14.0'} @@ -4449,8 +4518,8 @@ snapshots: '@ampproject/remapping@2.3.0': dependencies: - '@jridgewell/gen-mapping': 0.3.8 - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 '@asamuzakjp/css-color@3.2.0': dependencies: @@ -4472,7 +4541,7 @@ snapshots: dependencies: '@ampproject/remapping': 2.3.0 '@babel/code-frame': 7.27.1 - '@babel/generator': 7.27.1 + '@babel/generator': 7.28.5 '@babel/helper-compilation-targets': 7.27.2 '@babel/helper-module-transforms': 7.27.1(@babel/core@7.27.1) '@babel/helpers': 7.27.1 @@ -4488,12 +4557,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/generator@7.27.1': + '@babel/generator@7.28.5': dependencies: - '@babel/parser': 7.27.2 - '@babel/types': 7.27.1 - '@jridgewell/gen-mapping': 0.3.8 - '@jridgewell/trace-mapping': 0.3.25 + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 '@babel/helper-compilation-targets@7.27.2': @@ -4504,10 +4573,12 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 + '@babel/helper-globals@7.28.0': {} + '@babel/helper-module-imports@7.27.1': dependencies: '@babel/traverse': 7.27.1 - '@babel/types': 7.27.1 + '@babel/types': 7.28.5 transitivePeerDependencies: - supports-color @@ -4526,16 +4597,22 @@ snapshots: '@babel/helper-validator-identifier@7.27.1': {} + '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-option@7.27.1': {} '@babel/helpers@7.27.1': dependencies: '@babel/template': 7.27.2 - '@babel/types': 7.27.1 + '@babel/types': 7.28.5 '@babel/parser@7.27.2': dependencies: - '@babel/types': 7.27.1 + '@babel/types': 7.28.5 + + '@babel/parser@7.28.5': + dependencies: + '@babel/types': 7.28.5 '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.27.1)': dependencies: @@ -4552,26 +4629,43 @@ snapshots: '@babel/template@7.27.2': dependencies: '@babel/code-frame': 7.27.1 - '@babel/parser': 7.27.2 - '@babel/types': 7.27.1 + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 '@babel/traverse@7.27.1': dependencies: '@babel/code-frame': 7.27.1 - '@babel/generator': 7.27.1 - '@babel/parser': 7.27.2 + '@babel/generator': 7.28.5 + '@babel/parser': 7.28.5 '@babel/template': 7.27.2 - '@babel/types': 7.27.1 - debug: 4.4.1 + '@babel/types': 7.28.5 + debug: 4.4.3 globals: 11.12.0 transitivePeerDependencies: - supports-color + '@babel/traverse@7.28.5': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.5 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.28.5 + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + '@babel/types@7.27.1': dependencies: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 + '@babel/types@7.28.5': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@cloudflare/workerd-darwin-64@1.20240718.0': optional: true @@ -4842,7 +4936,7 @@ snapshots: dependencies: '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 22.15.21 + '@types/node': 24.10.0 jest-mock: 29.7.0 '@jest/expect-utils@29.7.0': @@ -4853,7 +4947,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@sinonjs/fake-timers': 10.3.0 - '@types/node': 22.15.21 + '@types/node': 24.10.0 jest-message-util: 29.7.0 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -4867,15 +4961,20 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 22.15.21 + '@types/node': 24.10.0 '@types/yargs': 17.0.33 chalk: 4.1.2 + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/gen-mapping@0.3.8': dependencies: '@jridgewell/set-array': 1.2.1 '@jridgewell/sourcemap-codec': 1.5.0 - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/trace-mapping': 0.3.31 '@jridgewell/resolve-uri@3.1.2': {} @@ -4888,6 +4987,11 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/trace-mapping@0.3.9': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -5435,20 +5539,24 @@ snapshots: '@types/babel__generator@7.27.0': dependencies: - '@babel/types': 7.27.1 + '@babel/types': 7.28.5 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.27.2 - '@babel/types': 7.27.1 + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 '@types/babel__traverse@7.20.7': dependencies: - '@babel/types': 7.27.1 + '@babel/types': 7.28.5 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.28.5 '@types/better-sqlite3@7.6.13': dependencies: - '@types/node': 22.15.21 + '@types/node': 24.10.0 '@types/estree@1.0.7': {} @@ -5469,7 +5577,7 @@ snapshots: '@types/jsdom@20.0.1': dependencies: - '@types/node': 22.15.21 + '@types/node': 24.10.0 '@types/tough-cookie': 4.0.5 parse5: 7.3.0 @@ -5483,9 +5591,9 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/node@22.15.21': + '@types/node@24.10.0': dependencies: - undici-types: 6.21.0 + undici-types: 7.16.0 '@types/parse-json@4.0.2': {} @@ -5516,7 +5624,7 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 22.15.21 + '@types/node': 24.10.0 '@types/yargs-parser@21.0.3': {} @@ -5610,14 +5718,14 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-react@4.4.1(vite@5.4.19(@types/node@22.15.21))': + '@vitejs/plugin-react@4.4.1(vite@5.4.19(@types/node@24.10.0))': dependencies: '@babel/core': 7.27.1 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.27.1) '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.27.1) '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 5.4.19(@types/node@22.15.21) + vite: 5.4.19(@types/node@24.10.0) transitivePeerDependencies: - supports-color @@ -5628,13 +5736,13 @@ snapshots: chai: 5.2.0 tinyrainbow: 1.2.0 - '@vitest/mocker@2.1.9(vite@5.4.19(@types/node@22.15.21))': + '@vitest/mocker@2.1.9(vite@5.4.19(@types/node@24.10.0))': dependencies: '@vitest/spy': 2.1.9 estree-walker: 2.0.2 magic-string: 0.30.17 optionalDependencies: - vite: 5.4.19(@types/node@22.15.21) + vite: 5.4.19(@types/node@24.10.0) '@vitest/pretty-format@2.1.9': dependencies: @@ -5690,7 +5798,7 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.4.1 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -5884,7 +5992,7 @@ snapshots: capnp-ts@0.7.0: dependencies: - debug: 4.4.1 + debug: 4.4.3 tslib: 2.8.1 transitivePeerDependencies: - supports-color @@ -6057,6 +6165,10 @@ snapshots: dependencies: ms: 2.1.3 + debug@4.4.3: + dependencies: + ms: 2.1.3 + decamelize@1.2.0: {} decimal.js@10.5.0: {} @@ -6674,7 +6786,7 @@ snapshots: dependencies: '@tootallnate/once': 2.0.0 agent-base: 6.0.2 - debug: 4.4.1 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -6688,7 +6800,7 @@ snapshots: https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 - debug: 4.4.1 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -6953,18 +7065,21 @@ snapshots: jest-mock@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 22.15.21 + '@types/node': 24.10.0 jest-util: 29.7.0 jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 22.15.21 + '@types/node': 24.10.0 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 picomatch: 2.3.1 + jiti@2.6.1: + optional: true + joycon@3.1.1: {} js-tokens@4.0.0: {} @@ -7402,10 +7517,11 @@ snapshots: possible-typed-array-names@1.1.0: {} - postcss-load-config@6.0.1(postcss@8.5.3)(tsx@4.19.4): + postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.3)(tsx@4.19.4): dependencies: lilconfig: 3.1.3 optionalDependencies: + jiti: 2.6.1 postcss: 8.5.3 tsx: 4.19.4 @@ -7464,7 +7580,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/node': 22.15.21 + '@types/node': 24.10.0 long: 5.3.2 psl@1.15.0: @@ -7959,13 +8075,13 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - svelte-preprocess@6.0.3(@babel/core@7.27.1)(postcss-load-config@6.0.1(postcss@8.5.3)(tsx@4.19.4))(postcss@8.5.3)(svelte@5.34.7)(typescript@5.8.3): + svelte-preprocess@6.0.3(@babel/core@7.27.1)(postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.3)(tsx@4.19.4))(postcss@8.5.3)(svelte@5.34.7)(typescript@5.8.3): dependencies: svelte: 5.34.7 optionalDependencies: '@babel/core': 7.27.1 postcss: 8.5.3 - postcss-load-config: 6.0.1(postcss@8.5.3)(tsx@4.19.4) + postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.3)(tsx@4.19.4) typescript: 5.8.3 svelte@5.34.7: @@ -8104,7 +8220,7 @@ snapshots: tslib@2.8.1: {} - tsup@8.5.0(postcss@8.5.3)(tsx@4.19.4)(typescript@5.8.3): + tsup@8.5.0(jiti@2.6.1)(postcss@8.5.3)(tsx@4.19.4)(typescript@5.8.3): dependencies: bundle-require: 5.1.0(esbuild@0.25.4) cac: 6.7.14 @@ -8115,7 +8231,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(postcss@8.5.3)(tsx@4.19.4) + postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.3)(tsx@4.19.4) resolve-from: 5.0.0 rollup: 4.41.0 source-map: 0.8.0-beta.0 @@ -8233,6 +8349,8 @@ snapshots: undici-types@6.21.0: {} + undici-types@7.16.0: {} + undici@5.29.0: dependencies: '@fastify/busboy': 2.1.1 @@ -8290,13 +8408,13 @@ snapshots: util-deprecate@1.0.2: {} - vite-node@2.1.9(@types/node@22.15.21): + vite-node@2.1.9(@types/node@24.10.0): dependencies: cac: 6.7.14 debug: 4.4.1 es-module-lexer: 1.7.0 pathe: 1.1.2 - vite: 5.4.19(@types/node@22.15.21) + vite: 5.4.19(@types/node@24.10.0) transitivePeerDependencies: - '@types/node' - less @@ -8308,30 +8426,30 @@ snapshots: - supports-color - terser - vite-tsconfig-paths@5.1.4(typescript@5.8.3)(vite@5.4.19(@types/node@22.15.21)): + vite-tsconfig-paths@5.1.4(typescript@5.8.3)(vite@5.4.19(@types/node@24.10.0)): dependencies: debug: 4.4.1 globrex: 0.1.2 tsconfck: 3.1.5(typescript@5.8.3) optionalDependencies: - vite: 5.4.19(@types/node@22.15.21) + vite: 5.4.19(@types/node@24.10.0) transitivePeerDependencies: - supports-color - typescript - vite@5.4.19(@types/node@22.15.21): + vite@5.4.19(@types/node@24.10.0): dependencies: esbuild: 0.25.4 postcss: 8.5.3 rollup: 4.41.0 optionalDependencies: - '@types/node': 22.15.21 + '@types/node': 24.10.0 fsevents: 2.3.3 - vitest@2.1.9(@types/node@22.15.21)(jsdom@25.0.1): + vitest@2.1.9(@types/node@24.10.0)(jsdom@25.0.1): dependencies: '@vitest/expect': 2.1.9 - '@vitest/mocker': 2.1.9(vite@5.4.19(@types/node@22.15.21)) + '@vitest/mocker': 2.1.9(vite@5.4.19(@types/node@24.10.0)) '@vitest/pretty-format': 2.1.9 '@vitest/runner': 2.1.9 '@vitest/snapshot': 2.1.9 @@ -8347,11 +8465,11 @@ snapshots: tinyexec: 0.3.2 tinypool: 1.0.2 tinyrainbow: 1.2.0 - vite: 5.4.19(@types/node@22.15.21) - vite-node: 2.1.9(@types/node@22.15.21) + vite: 5.4.19(@types/node@24.10.0) + vite-node: 2.1.9(@types/node@24.10.0) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 22.15.21 + '@types/node': 24.10.0 jsdom: 25.0.1 transitivePeerDependencies: - less From 6d3eba1717d3b6503043d65ad97592d3da554d23 Mon Sep 17 00:00:00 2001 From: Michael Kochell <6913320+mickmister@users.noreply.github.com> Date: Mon, 10 Nov 2025 02:29:04 -0500 Subject: [PATCH 02/15] fix typo. get file transformer tests working --- packages/springboard/cli/package.json | 4 +++- .../src/esbuild_plugins/esbuild_plugin_platform_inject.ts | 5 +++-- packages/springboard/cli/vite.config.ts | 3 +++ 3 files changed, 9 insertions(+), 3 deletions(-) create mode 100644 packages/springboard/cli/vite.config.ts diff --git a/packages/springboard/cli/package.json b/packages/springboard/cli/package.json index d6f45394..e3ccca95 100644 --- a/packages/springboard/cli/package.json +++ b/packages/springboard/cli/package.json @@ -29,7 +29,9 @@ "clean": "rm -rf dist", "add-header": "./scripts/add-node-executable-header.sh", "cli-docs": "npx tsx scripts/make_cli_docs.ts", - "check-types": "tsc --noEmit" + "check-types": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "commander": "catalog:", diff --git a/packages/springboard/cli/src/esbuild_plugins/esbuild_plugin_platform_inject.ts b/packages/springboard/cli/src/esbuild_plugins/esbuild_plugin_platform_inject.ts index e2e21b09..1b06421b 100644 --- a/packages/springboard/cli/src/esbuild_plugins/esbuild_plugin_platform_inject.ts +++ b/packages/springboard/cli/src/esbuild_plugins/esbuild_plugin_platform_inject.ts @@ -20,9 +20,10 @@ export const esbuildPluginPlatformInject = ( // Early return if file doesn't need any transformations const hasPlatformAnnotations = /@platform "(node|browser|react-native|fetch)"/.test(source); - const hasServerCalls = preserveServerStatesAndActions && /createServer(State|States|Action|Actions)/.test(source); + const hasServerCalls = /createServer(State|States|Action|Actions)/.test(source); + const needsServerProcessing = hasServerCalls && ((platform === 'browser' || platform === 'react-native') && !preserveServerStatesAndActions); - if (!hasPlatformAnnotations && !hasServerCalls) { + if (!hasPlatformAnnotations && !needsServerProcessing) { return { contents: source, loader: args.path.split('.').pop() as 'js', diff --git a/packages/springboard/cli/vite.config.ts b/packages/springboard/cli/vite.config.ts new file mode 100644 index 00000000..3d1f059e --- /dev/null +++ b/packages/springboard/cli/vite.config.ts @@ -0,0 +1,3 @@ +import config from '../../../configs/vite.config'; + +export default config; From 126f33c904e1114b0566a9146ff08dc885781245 Mon Sep 17 00:00:00 2001 From: Michael Kochell <6913320+mickmister@users.noreply.github.com> Date: Mon, 10 Nov 2025 16:43:10 -0500 Subject: [PATCH 03/15] clean up tests --- .../server_state_edge_cases.tsx | 6 +- .../state_sync_test/state_sync_test.tsx | 163 ------------------ .../esbuild_plugin_platform_inject.test.ts | 33 ++-- 3 files changed, 19 insertions(+), 183 deletions(-) delete mode 100644 apps/small_apps/state_sync_test/state_sync_test.tsx diff --git a/apps/small_apps/server_state_edge_cases/server_state_edge_cases.tsx b/apps/small_apps/server_state_edge_cases/server_state_edge_cases.tsx index 5d5f35a0..b6f2f207 100644 --- a/apps/small_apps/server_state_edge_cases/server_state_edge_cases.tsx +++ b/apps/small_apps/server_state_edge_cases/server_state_edge_cases.tsx @@ -23,9 +23,9 @@ springboard.registerModule('server_state_edge_cases', {}, async (moduleAPI) => { return { success: true, name }; }; - // Test 4: Regular createAction (should be stripped - testing backwards compatibility) + // Test 4: Regular createAction const regularAction1 = moduleAPI.createAction('regular1', {}, async () => { - console.log('Regular action - will be stripped in browser'); + console.log('Regular action - will be kept in browser'); return { data: 'regular' }; }); @@ -49,7 +49,7 @@ springboard.registerModule('server_state_edge_cases', {}, async (moduleAPI) => { const regularActions = moduleAPI.createActions({ // Inline arrow function inlineArrow: async () => { - console.log('Regular action that will be stripped'); + console.log('Regular action that will be kept'); return { type: 'regular' }; }, diff --git a/apps/small_apps/state_sync_test/state_sync_test.tsx b/apps/small_apps/state_sync_test/state_sync_test.tsx deleted file mode 100644 index 94d524fb..00000000 --- a/apps/small_apps/state_sync_test/state_sync_test.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import React from 'react'; -import springboard from 'springboard'; -import {Module, registerModule} from 'springboard/module_registry/module_registry'; -springboard - -type StateSyncTestState = { - sharedCounter: number; - serverSecretValue: string; - lastUpdated: number; -}; - -/** - * Test module demonstrating the difference between server-only state and shared state. - * - * - sharedCounter: Syncs across all clients in real-time - * - serverSecretValue: Only exists on server, never exposed to clients - * - lastUpdated: Timestamp of last update (shared) - */ -springboard.registerModule('state_sync_test', {}, async (moduleAPI) => { - const sharedState = await moduleAPI.statesAPI.createSharedState('counter', { - value: 0, - lastUpdated: Date.now(), - }); - - const serverState = await moduleAPI.statesAPI.createServerState('secret', { - apiKey: 'super-secret-key-12345', - internalCounter: 0, - }); - - // Actions to manipulate state - const actions = moduleAPI.createActions({ - incrementShared: async () => { - const current = sharedState.getState(); - sharedState.setState({ - value: current.value + 1, - lastUpdated: Date.now(), - }); - }, - - incrementServer: async () => { - const current = serverState.getState(); - serverState.setState({ - ...current, - internalCounter: current.internalCounter + 1, - }); - }, - - getServerValue: async () => { - // This action runs on the server and returns the server-only value - const current = serverState.getState(); - return {internalCounter: current.internalCounter}; - }, - }); - - // UI Component - const StateTestUI: React.FC = () => { - const shared = sharedState.useState(); - const [serverCount, setServerCount] = React.useState(null); - - const fetchServerCount = async () => { - const result = await actions.getServerValue(); - setServerCount(result.internalCounter); - }; - - React.useEffect(() => { - fetchServerCount(); - }, []); - - return ( -
-

State Synchronization Test

- -
-

✅ Shared State (Syncs to All Clients)

-

Counter Value: {shared.value}

-

Last Updated: {new Date(shared.lastUpdated).toLocaleTimeString()}

- -
- -
-

🔒 Server-Only State (Never Syncs to Clients)

-

Internal Counter: {serverCount ?? 'Loading...'}

-

Note: This value is fetched via RPC action, not synced automatically

-
- - -
-
- -
-

Testing Instructions

-
    -
  1. Open this page in two browser windows/tabs
  2. -
  3. Click "Increment Shared Counter" in one window - both windows update instantly
  4. -
  5. Click "Increment Server Counter" in one window - only updates when you click "Refresh"
  6. -
  7. Server-only state is never automatically synchronized to clients
  8. -
-
-
- ); - }; - - moduleAPI.registerRoute('/', {}, () => ); -}); diff --git a/packages/springboard/cli/src/esbuild_plugins/esbuild_plugin_platform_inject.test.ts b/packages/springboard/cli/src/esbuild_plugins/esbuild_plugin_platform_inject.test.ts index fa4cf0ff..8be1e848 100644 --- a/packages/springboard/cli/src/esbuild_plugins/esbuild_plugin_platform_inject.test.ts +++ b/packages/springboard/cli/src/esbuild_plugins/esbuild_plugin_platform_inject.test.ts @@ -14,15 +14,19 @@ describe('esbuild_plugin_platform_inject', () => { if (fs.existsSync(distPath)) { fs.rmSync(distPath, { recursive: true, force: true }); } - }); - it('should remove server states and strip action bodies in browser build', async () => { // Build the edge cases app using the CLI execSync(`npx tsx ${cliPath} build ${testAppPath}`, { cwd: path.resolve(rootDir, 'apps/small_apps'), stdio: 'inherit', + env: { + ...process.env, + NODE_ENV: 'production', + }, }); + }); + it('should remove server states and strip action bodies in browser build', async () => { // Read the browser build output const browserDistPath = path.join(distPath, 'browser/dist'); const jsFiles = fs.readdirSync(browserDistPath).filter(f => f.endsWith('.js') && f.startsWith('index-')); @@ -39,9 +43,9 @@ describe('esbuild_plugin_platform_inject', () => { // Verify regular actions are NOT stripped (they should have full bodies) expect(browserBuildContent).toContain('createAction("regular1"'); - expect(browserBuildContent).toContain('Regular action - will be stripped in browser'); - expect(browserBuildContent).toContain('regularActions'); - expect(browserBuildContent).toContain('Regular action that will be stripped'); + expect(browserBuildContent).toContain('Regular action - will be kept in browser'); + // expect(browserBuildContent).toContain('regularActions'); + expect(browserBuildContent).toContain('Regular action that will be kept'); // Verify server action calls exist but bodies are empty expect(browserBuildContent).toContain('createServerAction("serverAction1"'); @@ -50,13 +54,10 @@ describe('esbuild_plugin_platform_inject', () => { // Verify server action bodies are stripped (should not contain implementation details) expect(browserBuildContent).not.toContain('This should be removed from client'); - // Note: In non-minified builds, myHandler function declaration still exists as dead code - // In production builds with minification, it would be tree-shaken away - // The important thing is that the server secrets aren't exposed - expect(browserBuildContent).toContain('Variable handler'); // Dead code in dev builds + expect(browserBuildContent).toContain('Variable handler'); // Verify createServerActions bodies are empty - expect(browserBuildContent).toContain('serverActions'); + // expect(browserBuildContent).toContain('serverActions'); expect(browserBuildContent).not.toContain('Authenticating user:'); expect(browserBuildContent).not.toContain('Authorizing with keys'); @@ -85,15 +86,15 @@ describe('esbuild_plugin_platform_inject', () => { expect(nodeBuildContent).toContain('admin-key-123'); // Verify action bodies are intact - expect(nodeBuildContent).toContain('Regular action - will be stripped in browser'); + expect(nodeBuildContent).toContain('Regular action - will be kept in browser'); expect(nodeBuildContent).toContain('This should be removed from client'); expect(nodeBuildContent).toContain('Variable handler'); - expect(nodeBuildContent).toContain('Regular action that will be stripped'); + expect(nodeBuildContent).toContain('Regular action that will be kept'); // Verify server action bodies are intact expect(nodeBuildContent).toContain('Authenticating user:'); expect(nodeBuildContent).toContain('Authorizing with keys'); - expect(nodeBuildContent).toContain('hasPassword: !!config.dbPassword'); + expect(nodeBuildContent).toContain('hasPassword:'); }, 60000); @@ -105,11 +106,10 @@ describe('esbuild_plugin_platform_inject', () => { // Verify regular createAction keeps its body expect(browserBuildContent).toContain('createAction("regular1"'); - expect(browserBuildContent).toContain('Regular action - will be stripped in browser'); + expect(browserBuildContent).toContain('Regular action - will be kept in browser'); // Verify regular createActions keeps its bodies - expect(browserBuildContent).toContain('regularActions'); - expect(browserBuildContent).toContain('Regular action that will be stripped'); + expect(browserBuildContent).toContain('Regular action that will be kept'); // Verify createServerAction bodies are stripped expect(browserBuildContent).toContain('createServerAction("serverAction1"'); @@ -121,7 +121,6 @@ describe('esbuild_plugin_platform_inject', () => { expect(browserBuildContent).toContain('Variable handler'); // Verify createServerActions bodies are stripped - expect(browserBuildContent).toContain('serverActions'); expect(browserBuildContent).not.toContain('Authenticating user:'); }, 60000); }); From b50c87d0cdf7605df105f76723309ce8ee918878 Mon Sep 17 00:00:00 2001 From: Michael Kochell <6913320+mickmister@users.noreply.github.com> Date: Tue, 11 Nov 2025 22:36:33 -0500 Subject: [PATCH 04/15] cache kv values when initializing server state piece --- packages/springboard/core/engine/engine.tsx | 7 ++++- .../springboard/core/engine/module_api.ts | 20 ++++++++----- .../services/states/shared_state_service.ts | 30 +++++++++++++++++++ .../springboard/core/types/module_types.ts | 3 +- 4 files changed, 50 insertions(+), 10 deletions(-) diff --git a/packages/springboard/core/engine/engine.tsx b/packages/springboard/core/engine/engine.tsx index 1782a7d4..a78193f4 100644 --- a/packages/springboard/core/engine/engine.tsx +++ b/packages/springboard/core/engine/engine.tsx @@ -7,7 +7,7 @@ import React, {createContext, useContext, useState} from 'react'; import {useMount} from 'springboard/hooks/useMount'; import {ExtraModuleDependencies, Module, ModuleRegistry} from 'springboard/module_registry/module_registry'; -import {SharedStateService} from '../services/states/shared_state_service'; +import {SharedStateService, ServerStateService} from '../services/states/shared_state_service'; import {ModuleAPI} from './module_api'; type CapturedRegisterModuleCalls = [string, RegisterModuleOptions, ModuleCallback]; @@ -61,6 +61,7 @@ export class Springboard { private remoteSharedStateService!: SharedStateService; private localSharedStateService!: SharedStateService; + private serverStateService!: ServerStateService; initialize = async () => { const initStartTime = now(); @@ -97,6 +98,9 @@ export class Springboard { }); await this.localSharedStateService.initialize(); + this.serverStateService = new ServerStateService(this.coreDeps.storage.remote); + await this.serverStateService.initialize(); + this.moduleRegistry = new ModuleRegistry(); const registeredClassModuleCallbacks = (springboard.registerClassModule as unknown as {calls: CapturedRegisterClassModuleCalls[]}).calls || []; @@ -182,6 +186,7 @@ export class Springboard { services: { remoteSharedStateService: this.remoteSharedStateService, localSharedStateService: this.localSharedStateService, + serverStateService: this.serverStateService, }, }; }; diff --git a/packages/springboard/core/engine/module_api.ts b/packages/springboard/core/engine/module_api.ts index eb4d44d2..4b64bfc4 100644 --- a/packages/springboard/core/engine/module_api.ts +++ b/packages/springboard/core/engine/module_api.ts @@ -352,17 +352,21 @@ export class StatesAPI { public createServerState = async (stateName: string, initialValue: State): Promise> => { const fullKey = `${this.prefix}|state.server|${stateName}`; - // Load from persistent storage if available - const storedValue = await this.coreDeps.storage.remote.get(fullKey); - if (storedValue !== null && storedValue !== undefined) { - initialValue = storedValue; - } else if (this.coreDeps.isMaestro()) { - await this.coreDeps.storage.remote.set(fullKey, initialValue); + const cachedValue = this.modDeps.services.serverStateService.getCachedValue(fullKey) as State | undefined; + if (cachedValue !== undefined) { + initialValue = cachedValue; + } else { + const storedValue = await this.coreDeps.storage.remote.get(fullKey); + if (storedValue !== null && storedValue !== undefined) { + initialValue = storedValue; + } else if (this.coreDeps.isMaestro()) { + await this.coreDeps.storage.remote.set(fullKey, initialValue); + } } const supervisor = new ServerStateSupervisor(fullKey, initialValue); - // Subscribe to persist changes to storage, but do NOT broadcast to clients + // Subscribe to persist changes to storage, but do not broadcast to clients const sub = supervisor.subjectForKVStorePublish.subscribe(async value => { await this.coreDeps.storage.remote.set(fullKey, value); }); @@ -373,7 +377,7 @@ export class StatesAPI { /** * Create multiple server-only states at once. Convenience method for batch creation. - * Each state is saved in persistent storage but is NOT synced to clients. + * Each state is saved in persistent storage but is not synced to clients. */ public createServerStates = async >(states: States): Promise<{[K in keyof States]: StateSupervisor}> => { const keys = Object.keys(states); diff --git a/packages/springboard/core/services/states/shared_state_service.ts b/packages/springboard/core/services/states/shared_state_service.ts index 994b51d6..4bffb389 100644 --- a/packages/springboard/core/services/states/shared_state_service.ts +++ b/packages/springboard/core/services/states/shared_state_service.ts @@ -204,6 +204,36 @@ export class SharedStateSupervisor implements StateSupervisor { }; } +/** + * Server-only state service for caching server state values. + * Does not sync to clients - this is for server-side data only. + */ +export class ServerStateService { + private cache: Record = {}; + + constructor(private kv: KVStore) {} + + initialize = async () => { + const allValues = await this.kv.getAll(); + if (allValues) { + for (const key of Object.keys(allValues)) { + // Only cache keys that are server state (contain '|state.server|') + if (key.includes('|state.server|')) { + this.setCachedValue(key, allValues[key]); + } + } + } + }; + + getCachedValue = (key: string): Value | undefined => { + return this.cache[key]; + }; + + setCachedValue = (key: string, value: Value): void => { + this.cache[key] = value; + }; +} + /** * Server-only state supervisor that persists to storage but does NOT sync to clients. * This is useful for server-side data that should never be exposed to the client. diff --git a/packages/springboard/core/types/module_types.ts b/packages/springboard/core/types/module_types.ts index a2807151..a1d6ee55 100644 --- a/packages/springboard/core/types/module_types.ts +++ b/packages/springboard/core/types/module_types.ts @@ -1,5 +1,5 @@ import {Module, ModuleRegistry} from 'springboard/module_registry/module_registry'; -import {SharedStateService} from '../services/states/shared_state_service'; +import {SharedStateService, ServerStateService} from '../services/states/shared_state_service'; export type ModuleCallback = (coreDeps: CoreDependencies, modDependencies: ModuleDependencies) => Promise> | Module; @@ -63,5 +63,6 @@ export type ModuleDependencies = { services: { remoteSharedStateService: SharedStateService; localSharedStateService : SharedStateService; + serverStateService: ServerStateService; }; } From c36b6ebdbd47d9ab237ae33719ab05e479659f5f Mon Sep 17 00:00:00 2001 From: Michael Kochell <6913320+mickmister@users.noreply.github.com> Date: Wed, 12 Nov 2025 20:49:56 -0500 Subject: [PATCH 05/15] fix references to shared vs server kv --- package.json | 1 + packages/springboard/core/engine/engine.tsx | 8 +- .../springboard/core/engine/module_api.ts | 15 ++-- .../core/services/namespaced_kv_store.ts | 74 +++++++++++++++++++ .../services/states/shared_state_service.ts | 8 +- .../core/test/mock_core_dependencies.ts | 3 +- .../springboard/core/types/module_types.ts | 3 +- .../partykit_browser_entrypoint.tsx | 4 +- .../partykit/src/partykit_hono_app.ts | 9 ++- .../platform_react_native_browser.tsx | 4 +- .../rn_app_springboard_entrypoint.ts | 4 +- .../services/kv/kv_rn_and_webview.spec.tsx | 4 +- .../entrypoints/platform_tauri_browser.tsx | 10 ++- .../webapp/entrypoints/offline_entrypoint.ts | 9 ++- .../webapp/entrypoints/online_entrypoint.ts | 6 +- packages/springboard/server/src/hono_app.ts | 27 +++---- 16 files changed, 139 insertions(+), 50 deletions(-) create mode 100644 packages/springboard/core/services/namespaced_kv_store.ts diff --git a/package.json b/package.json index 36c9f686..f4f4e491 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "prestart": "bash -c \"./configs/scripts/check-dependencies.sh\"", "postinstall": "pnpm -r --filter springboard-cli run dev:setup", "build": "turbo run build", + "check-types": "turbo run check-types", "build-saas": "turbo run build-saas", "dev": "npm run dev-dev --prefix packages/springboard/cli", "docs": "cd docs && docker compose up", diff --git a/packages/springboard/core/engine/engine.tsx b/packages/springboard/core/engine/engine.tsx index a78193f4..aad4b7bf 100644 --- a/packages/springboard/core/engine/engine.tsx +++ b/packages/springboard/core/engine/engine.tsx @@ -81,7 +81,7 @@ export class Springboard { this.remoteSharedStateService = new SharedStateService({ rpc: this.coreDeps.rpc.remote, - kv: this.coreDeps.storage.remote, + kv: this.coreDeps.storage.shared, log: this.coreDeps.log, isMaestro: this.coreDeps.isMaestro, }); @@ -98,8 +98,10 @@ export class Springboard { }); await this.localSharedStateService.initialize(); - this.serverStateService = new ServerStateService(this.coreDeps.storage.remote); - await this.serverStateService.initialize(); + this.serverStateService = new ServerStateService(this.coreDeps.storage.server); + if (this.coreDeps.isMaestro()) { + await this.serverStateService.initialize(); + } this.moduleRegistry = new ModuleRegistry(); diff --git a/packages/springboard/core/engine/module_api.ts b/packages/springboard/core/engine/module_api.ts index 4b64bfc4..bb9fecc2 100644 --- a/packages/springboard/core/engine/module_api.ts +++ b/packages/springboard/core/engine/module_api.ts @@ -294,18 +294,18 @@ export class StatesAPI { if (cachedValue !== undefined) { initialValue = cachedValue; } else { - const storedValue = await this.coreDeps.storage.remote.get(fullKey); + const storedValue = await this.coreDeps.storage.shared.get(fullKey); if (storedValue !== null && storedValue !== undefined) { // this should really only use undefined for a signal initialValue = storedValue; } else if (this.coreDeps.isMaestro()) { - await this.coreDeps.storage.remote.set(fullKey, initialValue); + await this.coreDeps.storage.shared.set(fullKey, initialValue); } } const supervisor = new SharedStateSupervisor(fullKey, initialValue, this.modDeps.services.remoteSharedStateService); const sub = supervisor.subjectForKVStorePublish.subscribe(async value => { - await this.coreDeps.storage.remote.set(fullKey, value); + await this.coreDeps.storage.shared.set(fullKey, value); }); this.onDestroy(sub.unsubscribe); @@ -347,20 +347,21 @@ export class StatesAPI { /** * Create a piece of server-only state that is saved in persistent storage but is NOT synced to clients. * This is useful for sensitive server-side data that should never be exposed to the client. - * The state is still persisted to the remote storage (database/etc), but changes are not broadcast via RPC. + * The state is still persisted to the server storage (database/etc), but changes are not broadcast via RPC. */ public createServerState = async (stateName: string, initialValue: State): Promise> => { const fullKey = `${this.prefix}|state.server|${stateName}`; + // Check cache first (populated during serverStateService.initialize()) const cachedValue = this.modDeps.services.serverStateService.getCachedValue(fullKey) as State | undefined; if (cachedValue !== undefined) { initialValue = cachedValue; } else { - const storedValue = await this.coreDeps.storage.remote.get(fullKey); + const storedValue = await this.coreDeps.storage.server.get(fullKey); if (storedValue !== null && storedValue !== undefined) { initialValue = storedValue; } else if (this.coreDeps.isMaestro()) { - await this.coreDeps.storage.remote.set(fullKey, initialValue); + await this.coreDeps.storage.server.set(fullKey, initialValue); } } @@ -368,7 +369,7 @@ export class StatesAPI { // Subscribe to persist changes to storage, but do not broadcast to clients const sub = supervisor.subjectForKVStorePublish.subscribe(async value => { - await this.coreDeps.storage.remote.set(fullKey, value); + await this.coreDeps.storage.server.set(fullKey, value); }); this.onDestroy(sub.unsubscribe); diff --git a/packages/springboard/core/services/namespaced_kv_store.ts b/packages/springboard/core/services/namespaced_kv_store.ts new file mode 100644 index 00000000..eba6fd29 --- /dev/null +++ b/packages/springboard/core/services/namespaced_kv_store.ts @@ -0,0 +1,74 @@ +import {KVStore} from '../types/module_types'; + +/** + * NamespacedKVStore is a pure decorator that wraps a KVStore and prefixes all keys with a namespace. + * This provides storage-level isolation between different types of state (e.g., shared vs server). + * + * This decorator ONLY handles key namespacing - caching is handled by separate service layers. + */ +export class NamespacedKVStore implements KVStore { + /** + * @param inner - The underlying KVStore to wrap + * @param namespace - The prefix to add to all keys (e.g., 'shared:', 'server:') + */ + constructor( + private inner: KVStore, + private namespace: string + ) {} + + async get(key: string): Promise { + return this.inner.get(this.namespace + key); + } + + async set(key: string, value: T): Promise { + return this.inner.set(this.namespace + key, value); + } + + async getAll(): Promise | null> { + const allData = await this.inner.getAll(); + return this.filterAndStripNamespace(allData); + } + + /** + * Filters the data to only include keys that start with this namespace, + * and strips the namespace prefix from the keys in the returned object. + */ + private filterAndStripNamespace(data: Record | null): Record | null { + if (!data) { + return null; + } + + const filtered: Record = {}; + for (const [key, value] of Object.entries(data)) { + if (key.startsWith(this.namespace)) { + // Strip the namespace prefix from the key + const strippedKey = key.substring(this.namespace.length); + filtered[strippedKey] = value; + } + } + + return Object.keys(filtered).length > 0 ? filtered : null; + } +} + +/** + * NullKVStore is a stub implementation that throws errors for all operations. + * Used on client platforms where server state should never be accessed. + */ +export class NullKVStore implements KVStore { + private throwError(): never { + throw new Error('This KVStore is not available. Server state cannot be accessed from client platforms.'); + } + + async get(_key: string): Promise { + this.throwError(); + } + + async set(_key: string, _value: T): Promise { + this.throwError(); + } + + async getAll(): Promise | null> { + this.throwError(); + } +} diff --git a/packages/springboard/core/services/states/shared_state_service.ts b/packages/springboard/core/services/states/shared_state_service.ts index 4bffb389..b35f1325 100644 --- a/packages/springboard/core/services/states/shared_state_service.ts +++ b/packages/springboard/core/services/states/shared_state_service.ts @@ -206,7 +206,8 @@ export class SharedStateSupervisor implements StateSupervisor { /** * Server-only state service for caching server state values. - * Does not sync to clients - this is for server-side data only. + * Similar to SharedStateService but does not sync to clients via RPC. + * This is for server-side data only. */ export class ServerStateService { private cache: Record = {}; @@ -217,10 +218,7 @@ export class ServerStateService { const allValues = await this.kv.getAll(); if (allValues) { for (const key of Object.keys(allValues)) { - // Only cache keys that are server state (contain '|state.server|') - if (key.includes('|state.server|')) { - this.setCachedValue(key, allValues[key]); - } + this.setCachedValue(key, allValues[key]); } } }; diff --git a/packages/springboard/core/test/mock_core_dependencies.ts b/packages/springboard/core/test/mock_core_dependencies.ts index 557e5664..062a4e94 100644 --- a/packages/springboard/core/test/mock_core_dependencies.ts +++ b/packages/springboard/core/test/mock_core_dependencies.ts @@ -60,7 +60,8 @@ export const makeMockCoreDependencies = ({store}: MakeMockCoreDependenciesOption showError: console.error, log: () => {}, storage: { - remote: new MockKVStore(store), + shared: new MockKVStore(store), + server: new MockKVStore(store), userAgent: new MockKVStore(store), }, files: { diff --git a/packages/springboard/core/types/module_types.ts b/packages/springboard/core/types/module_types.ts index a1d6ee55..20068ec1 100644 --- a/packages/springboard/core/types/module_types.ts +++ b/packages/springboard/core/types/module_types.ts @@ -15,7 +15,8 @@ export type CoreDependencies = { saveFile: (name: string, content: string) => Promise; }; storage: { - remote: KVStore; + shared: KVStore; + server: KVStore; userAgent: KVStore; }; rpc: { diff --git a/packages/springboard/platforms/partykit/src/entrypoints/partykit_browser_entrypoint.tsx b/packages/springboard/platforms/partykit/src/entrypoints/partykit_browser_entrypoint.tsx index eac71dba..02eeb0fa 100644 --- a/packages/springboard/platforms/partykit/src/entrypoints/partykit_browser_entrypoint.tsx +++ b/packages/springboard/platforms/partykit/src/entrypoints/partykit_browser_entrypoint.tsx @@ -2,6 +2,7 @@ import {BrowserKVStoreService} from '@springboardjs/platforms-browser/services/browser_kvstore_service'; import {HttpKVStoreService} from 'springboard/services/http_kv_store_client'; +import {NullKVStore} from 'springboard/services/namespaced_kv_store'; import {startAndRenderBrowserApp} from '@springboardjs/platforms-browser/entrypoints/react_entrypoint'; import {PartyKitRpcClient} from '../services/partykit_rpc_client'; @@ -27,8 +28,9 @@ setTimeout(() => { remote: rpc, }, storage: { + shared: remoteKvStore, + server: new NullKVStore(), userAgent: userAgentKVStore, - remote: remoteKvStore, }, }); }); diff --git a/packages/springboard/platforms/partykit/src/partykit_hono_app.ts b/packages/springboard/platforms/partykit/src/partykit_hono_app.ts index e2fbe97d..4dcb5eb7 100644 --- a/packages/springboard/platforms/partykit/src/partykit_hono_app.ts +++ b/packages/springboard/platforms/partykit/src/partykit_hono_app.ts @@ -6,6 +6,7 @@ import {NodeLocalJsonRpcClientAndServer} from '@springboardjs/platforms-node/ser import {Springboard} from 'springboard/engine/engine'; import {makeMockCoreDependencies} from 'springboard/test/mock_core_dependencies'; +import {NamespacedKVStore} from 'springboard/services/namespaced_kv_store'; import {RpcMiddleware, ServerModuleAPI, serverRegistry} from 'springboard-server/src/register'; import {PartykitJsonRpcServer} from './services/partykit_rpc_server'; @@ -91,7 +92,10 @@ export const initApp = (coreDeps: InitArgs): InitAppReturnValue => { const mockDeps = makeMockCoreDependencies({store: {}}); - const kvStore = new PartykitKVStore(coreDeps.room, coreDeps.kvForHttp); + const baseKvStore = new PartykitKVStore(coreDeps.room, coreDeps.kvForHttp); + + const sharedKvStore = new NamespacedKVStore(baseKvStore, 'shared:'); + const serverKvStore = new NamespacedKVStore(baseKvStore, 'server:'); let storedEngine: Springboard | undefined; @@ -100,7 +104,8 @@ export const initApp = (coreDeps: InitArgs): InitAppReturnValue => { remote: rpc, }, storage: { - remote: kvStore, + shared: sharedKvStore, + server: serverKvStore, userAgent: mockDeps.storage.userAgent, }, injectEngine: (engine) => { diff --git a/packages/springboard/platforms/react-native/entrypoints/platform_react_native_browser.tsx b/packages/springboard/platforms/react-native/entrypoints/platform_react_native_browser.tsx index 68d1e7fd..43f21ab1 100644 --- a/packages/springboard/platforms/react-native/entrypoints/platform_react_native_browser.tsx +++ b/packages/springboard/platforms/react-native/entrypoints/platform_react_native_browser.tsx @@ -37,6 +37,7 @@ import {RpcWebviewToRN} from '../services/rpc/rpc_webview_to_rn'; import {WebviewToReactNativeKVService} from '../services/kv/kv_rn_and_webview'; import {BrowserJsonRpcClientAndServer} from '@springboardjs/platforms-browser/services/browser_json_rpc'; import {HttpKVStoreService} from 'springboard/services/http_kv_store_client'; +import {NullKVStore} from 'springboard/services/namespaced_kv_store'; import {ReactNativeWebviewLocalTokenService} from '../services/rn_webview_local_token_service'; export const startJamToolsAndRenderApp = async (args: {remoteUrl: string}): Promise => { @@ -132,7 +133,8 @@ export const createRNWebviewEngine = (props: {remoteRpc: Rpc, remoteKv: KVStore, log: console.log, showError: (error: string) => console.error(error), storage: { - remote: remoteKVStore, + shared: remoteKVStore, + server: new NullKVStore(), userAgent: userAgentKVStore, }, files: { diff --git a/packages/springboard/platforms/react-native/entrypoints/rn_app_springboard_entrypoint.ts b/packages/springboard/platforms/react-native/entrypoints/rn_app_springboard_entrypoint.ts index fecd0014..6c8ac6ae 100644 --- a/packages/springboard/platforms/react-native/entrypoints/rn_app_springboard_entrypoint.ts +++ b/packages/springboard/platforms/react-native/entrypoints/rn_app_springboard_entrypoint.ts @@ -4,6 +4,7 @@ import springboard from 'springboard'; import {Springboard} from 'springboard/engine/engine'; import {CoreDependencies, KVStore, Rpc} from 'springboard/types/module_types'; +import {NullKVStore} from 'springboard/services/namespaced_kv_store'; import {ReactNativeToWebviewKVService} from '../services/kv/kv_rn_and_webview'; import {RpcRNToWebview} from '../services/rpc/rpc_rn_to_webview'; @@ -108,7 +109,8 @@ export const createRNMainEngine = (props: { log: (...args) => console.log(...args), showError: (error) => console.error(error), storage: { - remote: new ReactNativeToWebviewKVService({rpc: localRpc, prefix: 'remote'}, props.asyncStorageDependency), + shared: new ReactNativeToWebviewKVService({rpc: localRpc, prefix: 'shared'}, props.asyncStorageDependency), + server: new NullKVStore(), userAgent: new ReactNativeToWebviewKVService({rpc: localRpc, prefix: 'userAgent'}, props.asyncStorageDependency), }, rpc: { diff --git a/packages/springboard/platforms/react-native/services/kv/kv_rn_and_webview.spec.tsx b/packages/springboard/platforms/react-native/services/kv/kv_rn_and_webview.spec.tsx index 341c1523..b748c59b 100644 --- a/packages/springboard/platforms/react-native/services/kv/kv_rn_and_webview.spec.tsx +++ b/packages/springboard/platforms/react-native/services/kv/kv_rn_and_webview.spec.tsx @@ -86,7 +86,7 @@ describe('KvRnWebview', () => { const rnEngine = createRNMainEngine({ remoteRpc: mockRemoteRpcForRN, - remoteKv: mockCoreDepsForRN.storage.remote, + remoteKv: mockCoreDepsForRN.storage.shared, onMessageFromRN, asyncStorageDependency: mockAsyncStorage, }); @@ -101,7 +101,7 @@ describe('KvRnWebview', () => { const webviewEngine = createRNWebviewEngine({ remoteRpc: mockRpcWebview, - remoteKv: mockCoreDepsForWebview.storage.remote, + remoteKv: mockCoreDepsForWebview.storage.shared, onMessageFromWebview, }); diff --git a/packages/springboard/platforms/tauri/entrypoints/platform_tauri_browser.tsx b/packages/springboard/platforms/tauri/entrypoints/platform_tauri_browser.tsx index 3b1eb842..a07e9785 100644 --- a/packages/springboard/platforms/tauri/entrypoints/platform_tauri_browser.tsx +++ b/packages/springboard/platforms/tauri/entrypoints/platform_tauri_browser.tsx @@ -7,6 +7,7 @@ import {appDataDir} from '@tauri-apps/api/path'; import {CoreDependencies} from 'springboard/types/module_types'; import {HttpKVStoreService} from 'springboard/services/http_kv_store_client'; +import {NullKVStore} from 'springboard/services/namespaced_kv_store'; import {Main} from '@springboardjs/platforms-browser/entrypoints/main'; // import {Main} from './main'; @@ -31,12 +32,12 @@ export const startAndRenderBrowserApp = async (): Promise => { const rpc = new BrowserJsonRpcClientAndServer(`${WS_HOST}/ws`); // const rpc = mockDeps.rpc; - const kvStore = new HttpKVStoreService(DATA_HOST); + const sharedKvStore = new HttpKVStoreService(DATA_HOST); - // const kvStore = new BrowserKVStoreService(localStorage); + // const sharedKvStore = new BrowserKVStoreService(localStorage); const userAgentKVStore = new BrowserKVStoreService(localStorage); - // const kvStore = mockDeps.storage.remote; + // const sharedKvStore = mockDeps.storage.shared; // const userAgentKVStore = mockDeps.storage.userAgent; const isLocal = false; @@ -46,7 +47,8 @@ export const startAndRenderBrowserApp = async (): Promise => { log: console.log, showError: (error: string) => console.error(error), storage: { - remote: kvStore, + shared: sharedKvStore, + server: new NullKVStore(), userAgent: userAgentKVStore, }, files: { diff --git a/packages/springboard/platforms/webapp/entrypoints/offline_entrypoint.ts b/packages/springboard/platforms/webapp/entrypoints/offline_entrypoint.ts index aa75a667..4b56bc7d 100644 --- a/packages/springboard/platforms/webapp/entrypoints/offline_entrypoint.ts +++ b/packages/springboard/platforms/webapp/entrypoints/offline_entrypoint.ts @@ -2,6 +2,7 @@ import {MockRpcService} from 'springboard/test/mock_core_dependencies'; import React from 'react'; import {BrowserKVStoreService} from '../services/browser_kvstore_service'; +import {NamespacedKVStore} from 'springboard/services/namespaced_kv_store'; import {startAndRenderBrowserApp} from './react_entrypoint'; (globalThis as {useHashRouter?: boolean}).useHashRouter = true; @@ -9,9 +10,12 @@ import {startAndRenderBrowserApp} from './react_entrypoint'; setTimeout(() => { const rpc = new MockRpcService(); - const remoteKvStore = new BrowserKVStoreService(localStorage); + const baseKvStore = new BrowserKVStoreService(localStorage); const userAgentKVStore = new BrowserKVStoreService(localStorage); + const sharedKvStore = new NamespacedKVStore(baseKvStore, 'shared:'); + const serverKvStore = new NamespacedKVStore(baseKvStore, 'server:'); + startAndRenderBrowserApp({ rpc: { remote: rpc, @@ -19,8 +23,9 @@ setTimeout(() => { }, isLocal: true, storage: { + shared: sharedKvStore, + server: serverKvStore, userAgent: userAgentKVStore, - remote: remoteKvStore, }, }); }); diff --git a/packages/springboard/platforms/webapp/entrypoints/online_entrypoint.ts b/packages/springboard/platforms/webapp/entrypoints/online_entrypoint.ts index d509865f..34416e04 100644 --- a/packages/springboard/platforms/webapp/entrypoints/online_entrypoint.ts +++ b/packages/springboard/platforms/webapp/entrypoints/online_entrypoint.ts @@ -1,6 +1,7 @@ import {BrowserJsonRpcClientAndServer} from '../services/browser_json_rpc'; import {BrowserKVStoreService} from '../services/browser_kvstore_service'; import {HttpKVStoreService} from 'springboard/services/http_kv_store_client'; +import {NullKVStore} from 'springboard/services/namespaced_kv_store'; import {startAndRenderBrowserApp} from './react_entrypoint'; let wsProtocol = 'ws'; @@ -18,7 +19,7 @@ const reloadJs = process.env.NODE_ENV === 'development' && process.env.RELOAD_JS setTimeout(() => { const rpc = new BrowserJsonRpcClientAndServer(`${WS_HOST}/ws`); - const remoteKvStore = new HttpKVStoreService(DATA_HOST); + const sharedKvStore = new HttpKVStoreService(DATA_HOST); const userAgentKVStore = new BrowserKVStoreService(localStorage); startAndRenderBrowserApp({ @@ -27,8 +28,9 @@ setTimeout(() => { local: undefined, }, storage: { + shared: sharedKvStore, + server: new NullKVStore(), userAgent: userAgentKVStore, - remote: remoteKvStore, }, dev: { reloadCss, diff --git a/packages/springboard/server/src/hono_app.ts b/packages/springboard/server/src/hono_app.ts index 7059fe7d..7d5f38c8 100644 --- a/packages/springboard/server/src/hono_app.ts +++ b/packages/springboard/server/src/hono_app.ts @@ -10,6 +10,7 @@ import {NodeAppDependencies} from '@springboardjs/platforms-node/entrypoints/mai import {KVStoreFromKysely} from '@springboardjs/data-storage/kv_api_kysely'; import {NodeKVStoreService} from '@springboardjs/platforms-node/services/node_kvstore_service'; import {NodeLocalJsonRpcClientAndServer} from '@springboardjs/platforms-node/services/node_local_json_rpc'; +import {NamespacedKVStore} from 'springboard/services/namespaced_kv_store'; import {NodeJsonRpcServer} from './services/server_json_rpc'; import {WebsocketServerCoreDependencies} from './ws_server_core_dependencies'; @@ -39,6 +40,9 @@ export const initApp = (kvDeps: WebsocketServerCoreDependencies): InitAppReturnV const remoteKV = new KVStoreFromKysely(kvDeps.kvDatabase); const userAgentStore = new NodeKVStoreService('userAgent'); + const sharedKV = new NamespacedKVStore(remoteKV, 'shared:'); + const serverKV = new NamespacedKVStore(remoteKV, 'server:'); + const rpc = new NodeLocalJsonRpcClientAndServer({ broadcastMessage: (message) => { return service.broadcastMessage(message); @@ -59,31 +63,17 @@ export const initApp = (kvDeps: WebsocketServerCoreDependencies): InitAppReturnV return c.json({error: 'No key provided'}, 400); } - const value = await remoteKV.get(key); + const value = await sharedKV.get(key); return c.json(value || null); }); app.post('/kv/set', async (c) => { - const body = await c.req.text(); - const {key, value} = JSON.parse(body); - - c.header('Content-Type', 'application/json'); - - if (!key) { - return c.json({error: 'No key provided'}, 400); - } - - if (!value) { - return c.json({error: 'No value provided'}, 400); - } - - await remoteKV.set(key, value); - return c.json({success: true}); + return c.json({error: 'Direct KV set is not allowed'}, 400); }); app.get('/kv/get-all', async (c) => { - const all = await remoteKV.getAll(); + const all = await sharedKV.getAll(); return c.json(all); }); @@ -187,7 +177,8 @@ export const initApp = (kvDeps: WebsocketServerCoreDependencies): InitAppReturnV local: undefined, }, storage: { - remote: remoteKV, + shared: sharedKV, + server: serverKV, userAgent: userAgentStore, }, injectEngine: (engine: Springboard) => { From 5143799bd7184c6d987866680f17a7d99e2dc6f8 Mon Sep 17 00:00:00 2001 From: Michael Kochell <6913320+mickmister@users.noreply.github.com> Date: Fri, 21 Nov 2025 05:41:53 -0500 Subject: [PATCH 06/15] refactor ModuleAPI to use namespaced structure and add build transformations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1: Core Refactor - Create namespace classes: ServerAPI, SharedAPI, UserAgentAPI, ClientAPI, UIAPI - Add namespaced methods: moduleAPI.server.*, moduleAPI.shared.*, moduleAPI.userAgent.*, moduleAPI.client.*, moduleAPI.ui.* - Migrate all modules to use new createSharedStates/createServerStates APIs - Maintain backward compatibility with deprecated methods - Add comprehensive JSDoc documentation Phase 2: Build System - Update esbuild plugin to detect new namespaced API patterns - Implement springboard.runOn() platform-specific transformation - Add compile-time code stripping for non-matching platforms - Create comprehensive test suite with 7 passing tests - Verify transformations work for browser/node/server builds All tests passing. Type checks passing (11/11 packages). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- CLAUDE.md | 185 ++++ .../app_with_splash_screen.tsx | 5 +- apps/small_apps/run_on_test/run_on_test.tsx | 65 ++ .../server_state_edge_cases.tsx | 11 +- apps/small_apps/tic_tac_toe/tic_tac_toe.tsx | 11 +- claude_notes/001-API_DESIGN_QUESTIONS.md | 561 +++++++++++ claude_notes/002-API_DESIGN_OBSERVATIONS.md | 549 ++++++++++ claude_notes/003-FINAL_API_DESIGN.md | 944 ++++++++++++++++++ claude_notes/004-CLARIFYING_QUESTIONS.md | 567 +++++++++++ claude_notes/005-IMPLEMENTATION_SPEC.md | 912 +++++++++++++++++ .../chord_families/chord_families_module.tsx | 17 +- .../jamtools/core/modules/io/io_module.tsx | 5 +- .../modules/macro_module/macro_module.tsx | 11 +- .../module_or_snack_template.tsx | 7 +- .../multi_octave_supervisor.tsx | 6 +- .../single_octave_root_mode_supervisor.tsx | 6 +- .../modules/daw_interaction_module.tsx | 8 +- .../modules/eventide/eventide_module.tsx | 8 +- .../midi_playback/midi_playback_module.tsx | 5 +- .../song_structures_dashboards_module.tsx | 14 +- .../ultimate_guitar_module.tsx | 20 +- .../esbuild_plugin_platform_inject.test.ts | 93 ++ .../esbuild_plugin_platform_inject.ts | 115 ++- .../springboard/core/engine/client_api.ts | 100 ++ .../core/engine/module_api.spec.ts | 6 +- .../springboard/core/engine/module_api.ts | 209 ++-- .../springboard/core/engine/server_api.ts | 168 ++++ .../springboard/core/engine/shared_api.ts | 175 ++++ packages/springboard/core/engine/ui_api.ts | 127 +++ .../springboard/core/engine/user_agent_api.ts | 181 ++++ .../core/modules/files/files_module.tsx | 16 +- .../services/kv/kv_rn_and_webview.spec.tsx | 10 +- 32 files changed, 4953 insertions(+), 164 deletions(-) create mode 100644 CLAUDE.md create mode 100644 apps/small_apps/run_on_test/run_on_test.tsx create mode 100644 claude_notes/001-API_DESIGN_QUESTIONS.md create mode 100644 claude_notes/002-API_DESIGN_OBSERVATIONS.md create mode 100644 claude_notes/003-FINAL_API_DESIGN.md create mode 100644 claude_notes/004-CLARIFYING_QUESTIONS.md create mode 100644 claude_notes/005-IMPLEMENTATION_SPEC.md create mode 100644 packages/springboard/core/engine/client_api.ts create mode 100644 packages/springboard/core/engine/server_api.ts create mode 100644 packages/springboard/core/engine/shared_api.ts create mode 100644 packages/springboard/core/engine/ui_api.ts create mode 100644 packages/springboard/core/engine/user_agent_api.ts diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..c115e7fe --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,185 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Jam Tools is a music performance system built on Springboard, a full-stack JavaScript framework. Springboard emphasizes realtime communication via WebSockets/JSON-RPC, side effect imports with dependency injection, and multi-platform deployment from a single codebase. + +## Repository Structure + +This is a pnpm monorepo with two main package families: + +- **`packages/springboard/`**: The Springboard framework itself + - `core/`: Core framework (modules, engine, services, hooks, types) + - `cli/`: CLI tool for building and running apps (`sb` command) + - `server/`: Server-side runtime (Hono-based WebSocket server) + - `platforms/`: Platform-specific implementations (browser, node, partykit, tauri, react-native) + - `data_storage/`: Key-value store implementations (SQLite/Kysely-based) + - `plugins/`: Framework plugins + +- **`packages/jamtools/`**: Jam Tools application code + - `core/`: MIDI, music-related core functionality + - `features/`: Feature modules + +- **`apps/`**: Application entry points + - `jamtools/`: Main Jam Tools application + - `small_apps/`: Test and example applications + +## Common Commands + +### Development +```bash +pnpm dev # Run dev server for main app +pnpm dev-without-node # Run dev excluding Node.js app +npm run dev-dev --prefix packages/springboard/cli # CLI dev mode +``` + +### Building +```bash +pnpm build # Build all packages +pnpm build-saas # Production build for SaaS deployment +turbo run build # Build using Turbo +``` + +### Testing +```bash +pnpm test # Run all tests +pnpm test:watch # Run tests in watch mode +vitest --run # Run tests in a specific package +``` + +Individual package tests: +```bash +cd packages/springboard/core && pnpm test +cd packages/jamtools/core && pnpm test:watch +``` + +### Linting and Type Checking +```bash +pnpm lint # Lint all packages +pnpm fix # Auto-fix lint issues +pnpm check-types # Type check all packages +turbo run check-types # Type check using Turbo +``` + +### CI Pipeline +```bash +pnpm ci # Run full CI: lint, check-types, build, test +``` + +### Springboard CLI +```bash +npx tsx packages/springboard/cli/src/cli.ts dev +npx tsx packages/springboard/cli/src/cli.ts build --platforms +``` + +Platforms: `browser`, `browser_offline`, `desktop` (Tauri), `partykit`, `all` + +## Architecture + +### Module System + +Springboard uses a module registration pattern. Modules are registered via side effect imports: + +```typescript +import springboard from 'springboard'; + +springboard.registerModule('ModuleName', {}, async (moduleAPI) => { + // Module initialization logic + // Use moduleAPI to register routes, actions, states, etc. +}); +``` + +**Key APIs:** +- `moduleAPI.registerRoute(path, options, component)` - Register React Router routes +- `moduleAPI.statesAPI` - Create shared/server/userAgent state pieces +- `moduleAPI.registerAction(name, callback)` - Register RPC actions +- `moduleAPI.registerNavigationItem(config)` - Add navigation items + +### State Management + +Springboard provides three types of state: + +1. **Shared State** (`SharedStateService`): Synchronized across all clients and server +2. **Server State** (`ServerStateService`): Server-only state, read-only from clients +3. **User Agent State**: Client-local persistent state + +States are managed through supervisors: +- `SharedStateSupervisor` - For shared state pieces +- `ServerStateSupervisor` - For server state pieces +- `UserAgentStateSupervisor` - For client-local state + +### RPC Communication + +Communication between client and server uses JSON-RPC over WebSockets. The framework provides: +- `Rpc` interface for calling/broadcasting/registering RPC methods +- Automatic reconnection handling +- Mode selection: `remote` (client-server) or `local` (same process) + +### Build System + +The CLI (`packages/springboard/cli`) uses esbuild with custom plugins: + +- **`esbuild_plugin_platform_inject`**: Conditionally includes code based on `@platform` directives +- **`esbuild_plugin_html_generate`**: Generates HTML entry files +- **`esbuild_plugin_partykit_config`**: Creates PartyKit configuration + +Platform-specific code blocks: +```typescript +// @platform "browser" +// Browser-only code +// @platform end + +// @platform "node" +// Node-only code +// @platform end +``` + +### Multi-Platform Support + +Single codebase deploys to multiple platforms: +- **Browser (online/offline)**: WebSocket-connected or standalone +- **Node**: Server-side runtime +- **Tauri**: Native desktop (maestro + webview bundles) +- **PartyKit**: Edge deployment +- **React Native**: Mobile (experimental) + +### Testing + +Tests use Vitest with: +- **Workspace configuration**: `vitest.workspace.ts` defines test projects +- **Per-package testing**: Each package has its own `vite.config.ts` +- **jsdom environment**: For React component testing +- **60s timeout**: `testTimeout: 1000 * 60` + +### TypeScript Configuration + +Each package has its own `tsconfig.json` (118 total). Root `tsconfig.json` provides shared configuration. Type checking is done per-package with `tsc --noEmit`. + +## Development Workflow + +1. **Install dependencies**: `pnpm install` (runs postinstall hook for springboard-cli setup) +2. **Start development**: `pnpm dev` or use CLI directly with `npx tsx` +3. **Make changes**: Edit source files (framework watches for changes in dev mode) +4. **Run tests**: `pnpm test` in specific package or root +5. **Type check**: `pnpm check-types` +6. **Lint**: `pnpm fix` to auto-fix issues +7. **Build**: `pnpm build` when ready for production + +## Key Files + +- `packages/springboard/core/engine/register.ts` - Module registration system +- `packages/springboard/core/engine/module_api.ts` - ModuleAPI implementation +- `packages/springboard/core/types/module_types.ts` - Core type definitions +- `packages/springboard/core/services/states/shared_state_service.ts` - State management +- `packages/springboard/cli/src/build.ts` - Build configuration and platform definitions +- `packages/springboard/server/src/hono_app.ts` - Server implementation +- `turbo.json` - Turborepo task configuration +- `pnpm-workspace.yaml` - Workspace package definitions + +## Current Branch Context + +Branch: `server-state` (PR typically targets `main`) + +Recent work involves server state caching and KV storage improvements. diff --git a/apps/small_apps/app_with_splash_screen/app_with_splash_screen.tsx b/apps/small_apps/app_with_splash_screen/app_with_splash_screen.tsx index cf160f03..18ee18e9 100644 --- a/apps/small_apps/app_with_splash_screen/app_with_splash_screen.tsx +++ b/apps/small_apps/app_with_splash_screen/app_with_splash_screen.tsx @@ -50,7 +50,10 @@ const CustomSplashScreen = () => { springboard.registerSplashScreen(CustomSplashScreen); springboard.registerModule('AppWithSplashScreen', {}, async (moduleAPI) => { - const messageState = await moduleAPI.statesAPI.createSharedState('message', 'Hello from the app with custom splash screen!'); + const states = await moduleAPI.shared.createSharedStates({ + message: 'Hello from the app with custom splash screen!', + }); + const messageState = states.message; await new Promise(r => setTimeout(r, 5000)); // fake waiting time diff --git a/apps/small_apps/run_on_test/run_on_test.tsx b/apps/small_apps/run_on_test/run_on_test.tsx new file mode 100644 index 00000000..b37da631 --- /dev/null +++ b/apps/small_apps/run_on_test/run_on_test.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import springboard from 'springboard'; + +// Test springboard.runOn() transformation + +springboard.registerModule('run_on_test', {}, async (moduleAPI) => { + // Test 1: runOn with node platform + const nodeDeps = springboard.runOn('node', () => { + console.log('Running on node'); + return { + platform: 'node', + secret: 'node-only-secret', + }; + }); + + // Test 2: runOn with browser platform + const browserDeps = springboard.runOn('browser', () => { + console.log('Running on browser'); + return { + platform: 'browser', + feature: 'browser-only-feature', + }; + }); + + // Test 3: runOn with async callback + const asyncDeps = await springboard.runOn('node', async () => { + console.log('Running async on node'); + return { + asyncData: 'node-async-data', + }; + }); + + // Test 4: runOn with fallback pattern + const deps = springboard.runOn('node', () => { + return {midi: 'node-midi-service'}; + }) ?? springboard.runOn('browser', () => { + return {audio: 'browser-audio-service'}; + }); + + const RunOnTestUI: React.FC = () => { + return ( +
+

springboard.runOn() Test

+
+

Expected Behavior:

+
    +
  • Node Build: nodeDeps and asyncDeps should have values, browserDeps should be null
  • +
  • Browser Build: browserDeps should have values, nodeDeps and asyncDeps should be null
  • +
+
+
+

Values:

+
nodeDeps: {JSON.stringify(nodeDeps, null, 2)}
+
browserDeps: {JSON.stringify(browserDeps, null, 2)}
+
asyncDeps: {JSON.stringify(asyncDeps, null, 2)}
+
deps: {JSON.stringify(deps, null, 2)}
+
+
+ ); + }; + + moduleAPI.ui.registerRoute('/', {}, () => ); + + return {}; +}); diff --git a/apps/small_apps/server_state_edge_cases/server_state_edge_cases.tsx b/apps/small_apps/server_state_edge_cases/server_state_edge_cases.tsx index b6f2f207..d56c1800 100644 --- a/apps/small_apps/server_state_edge_cases/server_state_edge_cases.tsx +++ b/apps/small_apps/server_state_edge_cases/server_state_edge_cases.tsx @@ -5,17 +5,20 @@ import springboard from 'springboard'; springboard.registerModule('server_state_edge_cases', {}, async (moduleAPI) => { // Test 1: Multiple server states at once using createServerStates - const serverStates = await moduleAPI.statesAPI.createServerStates({ + const serverStates = await moduleAPI.server.createServerStates({ userSession: { userId: 'user-123', token: 'secret-token' }, apiKeys: { stripe: 'sk_test_123', sendgrid: 'SG.xyz' }, internalCache: { lastSync: Date.now(), data: {} }, }); // Test 2: Single server state - const singleServerState = await moduleAPI.statesAPI.createServerState('config', { - dbPassword: 'super-secret-password', - adminKey: 'admin-key-123', + const singleServerStates = await moduleAPI.server.createServerStates({ + config: { + dbPassword: 'super-secret-password', + adminKey: 'admin-key-123', + }, }); + const singleServerState = singleServerStates.config; // Test 3: Function that returns a function (for actions) const createHandler = (name: string) => async () => { diff --git a/apps/small_apps/tic_tac_toe/tic_tac_toe.tsx b/apps/small_apps/tic_tac_toe/tic_tac_toe.tsx index 63dd8d7b..26042d61 100644 --- a/apps/small_apps/tic_tac_toe/tic_tac_toe.tsx +++ b/apps/small_apps/tic_tac_toe/tic_tac_toe.tsx @@ -53,9 +53,14 @@ const checkForWinner = (board: Board): Winner => { springboard.registerModule('TicTacToe', {}, async (moduleAPI) => { // TODO: springboard docs. if you need to wipe the initial state, you need to rename the state name // or "re-set" it right below for one run of the program - const boardState = await moduleAPI.statesAPI.createSharedState('board_v5', initialBoard); - const winnerState = await moduleAPI.statesAPI.createSharedState('winner', null); - const scoreState = await moduleAPI.statesAPI.createSharedState('score', {X: 0, O: 0, stalemate: 0}); + const states = await moduleAPI.shared.createSharedStates({ + board_v5: initialBoard as Board, + winner: null as Winner, + score: {X: 0, O: 0, stalemate: 0} as Score, + }); + const boardState = states.board_v5; + const winnerState = states.winner; + const scoreState = states.score; const actions = moduleAPI.createActions({ clickedCell: async (args: {row: number, column: number}) => { diff --git a/claude_notes/001-API_DESIGN_QUESTIONS.md b/claude_notes/001-API_DESIGN_QUESTIONS.md new file mode 100644 index 00000000..f89eeea5 --- /dev/null +++ b/claude_notes/001-API_DESIGN_QUESTIONS.md @@ -0,0 +1,561 @@ +# ModuleAPI Redesign - Questions for Newcomers & Validation + +## Background + +We're considering restructuring the ModuleAPI from a flat structure to a namespaced structure to improve discoverability, readability, and make server-only code more obvious for build-time optimizations. + +### Current API (Flat Structure) +```typescript +moduleAPI.createSharedStates({...}) +moduleAPI.createServerStates({...}) +moduleAPI.createUserAgentStates({...}) +moduleAPI.createActions({...}) +moduleAPI.createServerActions({...}) +moduleAPI.registerRoute('/', {}, Component) +moduleAPI.registerApplicationShell(Shell) +moduleAPI.statesAPI.createSharedState('key', value) +``` + +### Proposed API (Namespaced Structure) +```typescript +// Server-only (stripped from client builds) +moduleAPI.server.createStates({...}) +moduleAPI.server.createActions({...}) + +// Shared between clients and server (source of truth on server) +moduleAPI.shared.createStates({...}) +moduleAPI.shared.createActions({...}) + +// User agent specific (stored on client, React Native process, etc.) +moduleAPI.userAgent.createStates({...}) +moduleAPI.userAgent.createActions({...}) + +// UI-related +moduleAPI.ui.registerRoute('/', {}, Component) +moduleAPI.ui.registerApplicationShell(Shell) +moduleAPI.ui.registerReactProvider(Provider) // future +``` + +--- + +## Section 1: Conceptual Clarity + +### Q1.1: Domain Understanding +When you see these three namespaces (`server`, `shared`, `userAgent`), do you immediately understand the distinction? +- What would you expect `server` to mean? +- What would you expect `shared` to mean? +- What would you expect `userAgent` to mean? +- Are there any overlaps or ambiguities? + +> `server` means server-only +> `shared` means public and automatically sync'd with everyone connected +> `userAgent` means local to the user's device (not their auth'd account etc.). we may want to support state associated with the user's account and have a formal integration with `better-auth` + +### Q1.2: State Lifecycle Mental Model +For each namespace, can you describe in your own words: +- **Server states**: Where they're stored, who can access them, when they sync (if at all) +- **Shared states**: Where they're stored, who can access them, when they sync +- **User agent states**: Where they're stored, who can access them, when they sync + +> Server states do not sync. This is where things like user-provided API keys will go. Clients can never directly access these, unless requested via server action + +> Shared states are stored on the server, and stored in-memory on the client, sync'd via websockets whenever state values change. Probably could be stored in the user agent storage as well, e.g. localStorage + +> User agent states are stored in localStorage etc. + +### Q1.3: Mobile App Context +In a mobile app context (React Native + WebView): +- Where would you expect `userAgent` states to be stored? +- If you call `moduleAPI.userAgent.createActions`, where would you expect the action to run? +- Does this match your intuition, or does it feel surprising? + +> On the device +> On the device. If we wanted them to run in the same context, the dev should make a regular javascript function + +--- + +## Section 2: Naming & Consistency + +### Q2.1: Plural vs Singular Consistency +Should we use `createStates` everywhere or vary by namespace? + +**Option A - Consistent plural:** +```typescript +moduleAPI.server.createStates({...}) +moduleAPI.shared.createStates({...}) +moduleAPI.userAgent.createStates({...}) +``` + +**Option B - Namespace in method name:** +```typescript +moduleAPI.server.createServerStates({...}) +moduleAPI.shared.createSharedStates({...}) +moduleAPI.userAgent.createUserAgentStates({...}) +``` + +**Option C - Mix (as currently proposed):** +```typescript +moduleAPI.server.createStates({...}) +moduleAPI.shared.createStates({...}) // or createSharedStates? +moduleAPI.userAgent.createStates({...}) // or createUserAgentStates? +``` + +Which feels most natural? Which is least redundant while maintaining clarity? + +> What do you think? +> I think we *must* have `createServerStates` and `createServerActions` + +> Let's go option B + +### Q2.2: Actions Naming +For actions that run locally vs remotely: + +**Current proposal:** +```typescript +moduleAPI.shared.createActions({...}) // Can run local or remote +moduleAPI.userAgent.createActions({...}) // Runs on user agent (RN process for mobile) +moduleAPI.server.createActions({...}) // Always runs on server +``` + +Questions: +- Does `shared.createActions` clearly communicate that it can run locally OR remotely? +- Should there be a `local` vs `remote` distinction in the API itself? +- Is `userAgent.createActions` clear that it runs on the React Native process (not webview)? + +### Q2.3: UI Namespace +Should UI-related methods be under `moduleAPI.ui.*` or remain at the top level? + +**Option A - Namespaced:** +```typescript +moduleAPI.ui.registerRoute('/', {}, Component) +moduleAPI.ui.registerApplicationShell(Shell) +moduleAPI.ui.registerReactProvider(Provider) +``` + +**Option B - Top-level:** +```typescript +moduleAPI.registerRoute('/', {}, Component) +moduleAPI.registerApplicationShell(Shell) +moduleAPI.registerReactProvider(Provider) +``` + +Which feels more natural? Does `ui` add clarity or just verbosity? + +> `ui` adds clarity + +--- + +## Section 3: Developer Experience + +### Q3.1: Discoverability +Imagine you're typing `moduleAPI.` and your IDE shows autocomplete: + +**Current (Flat):** +``` +createAction +createActions +createServerAction +createServerActions +createSharedStates +createServerStates +createUserAgentStates +registerRoute +registerApplicationShell +statesAPI +deps +``` + +**Proposed (Namespaced):** +``` +deps +getModule +onDestroy +server +shared +userAgent +ui +``` + +Questions: +- Which is easier to navigate? +- Does the namespaced version make it easier or harder to discover functionality? +- When would you prefer the flat structure? When would you prefer namespaced? + +> I like the nested one + +### Q3.2: Common Use Cases +For the most common use case (creating shared states and actions), which feels better? + +**Current:** +```typescript +const states = await moduleAPI.createSharedStates({ + board: initialBoard, + winner: null +}) + +const actions = moduleAPI.createActions({ + clickedCell: async (args) => { /* ... */ } +}) +``` + +**Proposed:** +```typescript +const states = await moduleAPI.shared.createStates({ + board: initialBoard, + winner: null +}) + +const actions = moduleAPI.shared.createActions({ + clickedCell: async (args) => { /* ... */ } +}) +``` + +Is the extra `.shared` worth the explicitness? + +> Yeah I think it's worth it + +### Q3.3: Learning Curve +As a newcomer: +- Would you find the namespaced structure easier or harder to learn? +- Does it help you build a mental model faster, or does it add cognitive overhead? +- Would you need to constantly refer to docs, or could you guess the API? + +--- + +## Section 4: Build-Time Transformations + +### Q4.1: Compiler Detection +The build system needs to detect and strip server-only code. Which is easier to reason about? + +**Current approach:** +```typescript +// Compiler looks for: createServerState, createServerStates, createServerAction, createServerActions +const serverState = await moduleAPI.createServerStates({...}) +const serverAction = moduleAPI.createServerActions({...}) +``` + +**Proposed approach:** +```typescript +// Compiler looks for: moduleAPI.server.* +const serverState = await moduleAPI.server.createStates({...}) +const serverAction = moduleAPI.server.createActions({...}) +``` + +Questions: +- Which pattern is more intuitive for understanding "this code won't run in the browser"? +- Does seeing `moduleAPI.server.*` make it more obvious that code will be stripped? +- Is it valuable to have the server-only nature in the namespace vs the method name? + +> I think only the method name matters on this aspect. Either work. Let's go with proposed approach + +### Q4.2: Code Review & Auditing +When reviewing code for security (ensuring secrets don't leak to client): + +**Current:** +```typescript +const apiKeys = await moduleAPI.statesAPI.createServerStates({ + stripeKey: 'sk_live_...', + dbPassword: 'password123' +}) +``` + +**Proposed:** +```typescript +const apiKeys = await moduleAPI.server.createStates({ + stripeKey: 'sk_live_...', + dbPassword: 'password123' +}) +``` + +Is `moduleAPI.server.*` a more obvious visual signal during code review? + +> Yeah it's more obvious + +--- + +## Section 5: Migration & Backwards Compatibility + +### Q5.1: Migration Path +If we make this change, how should we handle migration? + +**Option A - Hard break:** +- Remove old methods entirely +- Force everyone to migrate at once +- Clear documentation + +**Option B - Deprecation period:** +- Keep old methods with deprecation warnings +- Gradual migration over several versions +- Both APIs work simultaneously + +**Option C - Aliases:** +- Old methods become aliases to new ones +- Keep both forever for compatibility + +Which would you prefer as a framework user? + +> Hard break for this effort + +### Q5.2: StatesAPI Exposure +Currently `moduleAPI.statesAPI` is exposed. In the new design: + +**Option A - Remove it:** +```typescript +// No longer exposed +// moduleAPI.statesAPI.createSharedState(...) ❌ +``` + +**Option B - Keep it as deprecated:** +```typescript +// Still works but deprecated +moduleAPI.statesAPI.createSharedState(...) // Shows warning +``` + +**Option C - Internal only:** +```typescript +// Available but documented as internal API +moduleAPI._internal.statesAPI // or similar +``` + +Should `statesAPI` remain exposed at all? + +> I think `_internal` is fine, and I think `_internal.deps` makes sense too + +--- + +## Section 6: Future Extensibility + +### Q6.1: Plugin System +If modules/plugins want to extend ModuleAPI with their own namespaces: + +```typescript +// Imagine a plugin adds: +moduleAPI.database.query(...) +moduleAPI.auth.login(...) +moduleAPI.payments.createCheckout(...) +``` + +Does the namespaced approach make this extension pattern clearer and more maintainable? + +> I think `getModule` is better. Not sure how I feel about editing moduleAPI. In fact I think we should `Object.freeze` most things set up by the framework + +### Q6.2: Platform-Specific APIs +In the future, we might have platform-specific methods: + +```typescript +moduleAPI.platform.desktop.createTrayIcon(...) +moduleAPI.platform.mobile.requestPermission(...) +moduleAPI.platform.web.registerServiceWorker(...) +``` + +Does this nested namespace pattern feel natural or too deep? + +> No let's not do this. Seems like too much to maintain, and is not the goal of the framework to make opinionated APIs to things like this + +--- + +## Section 7: Documentation & Communication + +### Q7.1: Tutorial Flow +For the quickstart tutorial, which is easier to explain to a newcomer? + +**Current approach:** +> "To create state that syncs across devices, use `moduleAPI.createSharedStates()`. For server-only state, use `moduleAPI.createServerStates()`." + +**Proposed approach:** +> "State is organized by where it lives: `moduleAPI.server.*` for server-only, `moduleAPI.shared.*` for synced state, `moduleAPI.userAgent.*` for client-only." + +Which mental model is easier to teach? + +### Q7.2: Error Messages +When someone uses the wrong API, which error is more helpful? + +**Current:** +``` +Error: Cannot call createServerStates() from client. +Did you mean createSharedStates()? +``` + +**Proposed:** +``` +Error: moduleAPI.server is not available in client builds. +Use moduleAPI.shared or moduleAPI.userAgent instead. +``` + +### Q7.3: Documentation Structure +How would you organize the docs? + +**Option A - By namespace:** +- Server API (`moduleAPI.server.*`) +- Shared API (`moduleAPI.shared.*`) +- UserAgent API (`moduleAPI.userAgent.*`) +- UI API (`moduleAPI.ui.*`) + +**Option B - By functionality:** +- State Management +- Actions +- UI Registration +- Dependencies + +**Option C - By use case:** +- Creating multiplayer state +- Server-side logic +- Client-side storage +- Routing + +> Option A used to be autogenerated by typedoc. let's bring that back. look at commit e455efe4e416c54fdee0146e893be2b7aa1bf1e2 to see what changed + +--- + +## Section 8: Edge Cases & Gotchas + +### Q8.1: Hybrid Actions +What if an action needs to run on both server and client at different times? + +```typescript +// Does this make sense? +moduleAPI.shared.createActions({ + saveData: async (data, options) => { + // Sometimes called with { mode: 'local' } + // Sometimes called with { mode: 'remote' } + } +}) +``` + +Should this be in `shared`, or should there be explicit `local` and `remote` namespaces? + +> I think shared makes sense. I don't necessarily "like" it but it's the best I've come up with + +### Q8.2: State Access Patterns +If I create a server state, should I be able to reference it from client code? + +```typescript +// On server +const serverState = await moduleAPI.server.createStates({ + secret: 'my-secret-key' +}) + +// Later in client code - should this work? +const value = serverState.getState() // ❌ Compile error? Runtime error? Return null? +``` + +What's the expected behavior? + +> Definitely some sort of error. We could have a stub client-side and throw an error on access. Right now we remove it entirely since the client has no business of it existing. Let's go stub&error route + +### Q8.3: TypeScript Inference +Should TypeScript prevent you from using server APIs in client code? + +```typescript +if (isServer) { + // TypeScript error: "moduleAPI.server" is not available in client builds + const state = await moduleAPI.server.createStates({...}) +} +``` + +Would this be helpful or annoying? + +> Typescript can't help here because every file in the framework is assumed to be isomorphic + +--- + +## Section 9: Comparison with Other Frameworks + +### Q9.1: Similar Patterns +Are there other frameworks with similar namespace patterns you've used? + +Examples: +- Next.js: `use server` / `use client` directives +- tRPC: `t.procedure.*` namespacing +- Remix: `loader` / `action` separation + +How does this proposal compare? What can we learn from them? + +> Not super familiar with Remix's choices. I want to lean on react-query, and use the APIs I've suggested for functions and states + +### Q9.2: Industry Conventions +Do you have strong opinions about where the "execution location" should be specified? + +**Option A - Namespace:** +```typescript +moduleAPI.server.createStates({...}) +``` + +**Option B - Method name:** +```typescript +moduleAPI.createServerStates({...}) +``` + +**Option C - Directive/decorator:** +```typescript +'use server' +moduleAPI.createStates({...}) +``` + +**Option D - Configuration:** +```typescript +moduleAPI.createStates({...}, { location: 'server' }) +``` + +> I think Option A is the best. `server` stands out this way. Though I think it should be `moduleAPI.server.createServerActions` + +--- + +## Section 10: Final Thoughts + +### Q10.1: Overall Preference +After reading through this document, which API style do you prefer overall and why? + +> Overall nested stuff + +### Q10.2: Deal Breakers +Are there any aspects of the proposed design that would be deal breakers for you? + +> Idk + +### Q10.3: Biggest Win +What's the single biggest improvement this change would bring? + +> Easy to navigate autocomplete and easier to read server-only things + +### Q10.4: Biggest Risk +What's the single biggest risk or downside of making this change? + +> Nothing + +### Q10.5: Alternative Proposals +Do you have any alternative design ideas that might achieve the same goals better? + +> Nope. Can you come up with something? + +--- + +## Your Context + +Please answer these questions while considering: +- You're a newcomer to Springboard framework +- You have experience with modern React/TypeScript +- You may or may not have experience with server/client state management +- You want an API that's discoverable, type-safe, and hard to misuse + +--- + +## Next Steps + +Based on feedback to these questions, we'll: +1. Refine the API design +2. Create migration guides +3. Update documentation +4. Implement the changes +5. Add comprehensive JSDoc comments + +> No migration guides are necessary + +> I also want to add "client actions" which is essentially a way for the server to call functions on the client. That's already happening in one case, and is how the shared state sync works /Users/mickmister/code/jamtools-worktrees/server-state-jamtools-worktree/packages/springboard/core/services/states/shared_state_service.ts:105 +1: private receiveRpcSetSharedState = async (args: SharedStateMessage) => { + + + +> Should we have `createStates` or explicit `createSharedStates`? Brevity is nice, and redundancy of repeating `shared` in `moduleAPI.shared.createSharedStates` feels weird + +> I also plan to have a `createRecordBasedState` which handles ids for updating objects etc. diff --git a/claude_notes/002-API_DESIGN_OBSERVATIONS.md b/claude_notes/002-API_DESIGN_OBSERVATIONS.md new file mode 100644 index 00000000..75b0fcc0 --- /dev/null +++ b/claude_notes/002-API_DESIGN_OBSERVATIONS.md @@ -0,0 +1,549 @@ +# API Design Observations & Recommendations + +Based on your feedback, here are the key decisions and recommendations for the ModuleAPI redesign. + +--- + +## ✅ Core Decisions Summary + +### 1. Namespaced Structure with Explicit Method Names +**Decision:** Use namespaces with explicit method names (Option B) + +```typescript +moduleAPI.server.createServerStates({...}) +moduleAPI.server.createServerActions({...}) +moduleAPI.shared.createSharedStates({...}) +moduleAPI.shared.createSharedActions({...}) +moduleAPI.userAgent.createUserAgentStates({...}) +moduleAPI.userAgent.createUserAgentActions({...}) +moduleAPI.ui.registerRoute('/', {}, Component) +``` + +**Rationale:** +- `server` namespace stands out visually during code review +- Method names are explicit about what they create +- Avoids relying solely on namespace for meaning +- Better grep-ability and searchability + +### 2. Internal APIs +Move current public APIs to `_internal`: +```typescript +moduleAPI._internal.statesAPI +moduleAPI._internal.deps +``` + +And freeze most framework-set objects to prevent mutation. + +### 3. Migration Strategy +**Hard break** - No deprecation period, no migration guides. Clean slate. + +### 4. Documentation +Regenerate API docs by namespace using typedoc (see commit `e455efe4e416c54fdee0146e893be2b7aa1bf1e2`) + +--- + +## 🤔 Open Question: Redundancy vs Brevity + +You raised an important point: +> Should we have `createStates` or explicit `createSharedStates`? Brevity is nice, and redundancy of repeating `shared` in `moduleAPI.shared.createSharedStates` feels weird + +### Option 1: Short & DRY (Reduced Redundancy) +```typescript +moduleAPI.server.createStates({...}) +moduleAPI.server.createActions({...}) +moduleAPI.shared.createStates({...}) +moduleAPI.shared.createActions({...}) +moduleAPI.userAgent.createStates({...}) +moduleAPI.userAgent.createActions({...}) +``` + +**Pros:** +- Less typing, cleaner code +- Namespace already tells you the scope +- More pleasant to use frequently + +**Cons:** +- Build transformations need to look at namespace + method (e.g., `moduleAPI.server.createStates`) +- Slightly less grep-able +- All methods have same name across namespaces + +### Option 2: Explicit & Verbose +```typescript +moduleAPI.server.createServerStates({...}) +moduleAPI.server.createServerActions({...}) +moduleAPI.shared.createSharedStates({...}) +moduleAPI.shared.createSharedActions({...}) +moduleAPI.userAgent.createUserAgentStates({...}) +moduleAPI.userAgent.createUserAgentActions({...}) +``` + +**Pros:** +- Crystal clear what you're creating +- Easy to grep for "createServerStates" or "createSharedStates" +- Build transformations can match on method name alone if needed +- Reads well in isolation (code snippets, error messages) + +**Cons:** +- Feels redundant: `moduleAPI.shared.createSharedStates` +- More typing + +### Recommendation: **Hybrid Approach** + +Keep explicit names for **server** (needed for build transformations), but use short names elsewhere: + +```typescript +// Server - explicit (needed for compiler detection & security review) +moduleAPI.server.createServerStates({...}) +moduleAPI.server.createServerActions({...}) + +// Shared - short (most common, prioritize DX) +moduleAPI.shared.createStates({...}) +moduleAPI.shared.createActions({...}) + +// UserAgent - short +moduleAPI.userAgent.createStates({...}) +moduleAPI.userAgent.createActions({...}) +``` + +**Rationale:** +- Server methods MUST be explicit for security/code review visibility +- Shared methods are most frequently used → optimize for ergonomics +- UserAgent methods are less common → short names are fine +- Build transformations already need namespace detection for tree-shaking + +> I don't think examples in this repo are condusive to real programs. And usage of each pattern will be specific to the app being developed. +> Let's go verbose and ask for community feedback. Let's put that in the docs. + +--- + +## 💡 New Feature: Client Actions + +You mentioned wanting to add "client actions" - server-to-client RPC calls. + +### Current Pattern +Already exists for shared state sync: +```typescript +// packages/springboard/core/services/states/shared_state_service.ts:105 +private receiveRpcSetSharedState = async (args: SharedStateMessage) => { +``` + +### Proposed API + +**Option 1: Dedicated namespace** +```typescript +moduleAPI.client.createClientActions({ + showNotification: async (args: {message: string}) => { + // Runs on client when server calls it + }, + updateUIState: async (args: {key: string, value: any}) => { + // ... + } +}) + +// Server-side usage +await someClient.actions.showNotification({message: 'Hello'}) +``` + +**Option 2: Under userAgent namespace** +```typescript +moduleAPI.userAgent.createClientCallableActions({ + showNotification: async (args) => { /* ... */ } +}) +``` + +**Option 3: Explicit direction in name** +```typescript +moduleAPI.createServerToClientActions({ + showNotification: async (args) => { /* ... */ } +}) +``` + +### Recommendation: **Option 1 with clarity** + +```typescript +// Define client-side actions that server can invoke +moduleAPI.client.createActions({ + showNotification: async (args: {message: string}) => { + // Runs on this client + } +}) + +// Or more explicit: +moduleAPI.client.createClientActions({...}) +``` + +**Rationale:** +- Symmetric with `server` namespace +- Clear that these run on client +- Natural place for client-specific APIs +- Distinguishes from `userAgent` (device storage) vs `client` (network participant) + +**Alternative naming:** +- `moduleAPI.rpc.client.*` and `moduleAPI.rpc.server.*` - more explicit about RPC nature +- `moduleAPI.client.*` for client-initiated, `moduleAPI.receiver.*` for server-initiated + + +> Client actions are special though. Take this block for example: + +```ts +springboard.registerModule('Main', {}, async (moduleAPI) => { + + + const clientActions = moduleAPI.client.createClientActions({ + toast: async (args: {message: string; id?: string /* ...other mantine notifications props with sane defaults */}) => { + const const newId = await createOrUpdateToast(args); + return {toastId}; + }, + }); + + const serverActions = moduleAPI.server.createServerActions({ + doTheThing: async (args: {userInput: string}, userContext) => { // userContext will be provided by the framework and contains things like connect id + const {toastId} = await clientActions.toast({message: 'Starting operation'}, userContext); // optionally pass addtional properties like {mode: 'broadcast_exclude_current_user'}, or {mode: 'broadcast'}. we should also have examples of integrating with things like mantine notifications + + const result = await doSomeWork(args).onProgress(progress => { + clientActions.toast({message: `Operation ${progress}% complete`, id: toastId}, userContext); + }); + }, + }); +}); +``` + +--- + +## 🗂️ Future Feature: Record-Based State + +You mentioned planning `createRecordBasedState` for handling IDs/updating objects. + +### Suggested API + +```typescript +// Under each namespace +moduleAPI.shared.createRecordBasedStates({ + users: { + // Schema/initial records + initialRecords: [ + {id: '1', name: 'Alice'}, + {id: '2', name: 'Bob'} + ] + }, + tasks: { + initialRecords: [] + } +}) + +// Usage +users.add({id: '3', name: 'Charlie'}) +users.update('1', {name: 'Alice Updated'}) +users.remove('2') +users.getById('1') +users.getAll() +``` + +> + +> Maybe a `replace` to signify we're replacing the whole value + +> Maybe a `upsert` for "id may exist" + +> I also want a `moduleAPI.server.runOnServer(() => {}), and one for client as well. Need to determine react native vs rn webview differences. I think client actions should run in the webview, and if the dev wants something to run in RN process, they can call a userAgent action from the client action. Here's a use case: + +```ts +const serverStates = await m.server.createRecordStates({ + myState: { + values: [], + version: 3, + migrate: async (current: {state, version}) => { // added this after writing this example. I think this is a cool pattern to provide + + }, + }, +}); + +await m.server.runOnServer(async () => { + switch (serverStates.myState.version) { + case 1: + // ...migrate to version 2 + // fallthrough + case 2: + // ...migrate to version 3 + // fallthrough + case 3: + // nothing to migrate + break; + default: + // throw error or handle unknown version + } +}); +``` + +**Questions to consider:** +- Should it be `createRecordStates` or `createRecordBasedStates`? +- Should records require an `id` field, or accept a key function? +- Should it be under each namespace or a separate concern? + +> `createRecordStates` looks good +> Let's require an id +> Under each namespace. But that that gets wordy right: + +```ts +m.server.createServerRecordStates() +``` + +### Recommendation: **Namespace-specific** + +```typescript +moduleAPI.server.createRecordStates({...}) +moduleAPI.shared.createRecordStates({...}) +moduleAPI.userAgent.createRecordStates({...}) +``` + +Keeps the pattern consistent - each namespace has same capabilities, just different scope/sync behavior. + +> We'll want to compile out `server.createServerRecordStates`, so I think we should use this one + +--- + +## 🏗️ Proposed Final API Surface + +```typescript +// Server-only (stripped from client builds) +moduleAPI.server.createServerStates({...}) +moduleAPI.server.createServerActions({...}) +moduleAPI.server.createRecordStates({...}) // future + +// Shared/synced (source of truth on server) +moduleAPI.shared.createStates({...}) +moduleAPI.shared.createActions({...}) +moduleAPI.shared.createRecordStates({...}) // future + +// User agent (device storage) +moduleAPI.userAgent.createStates({...}) +moduleAPI.userAgent.createActions({...}) +moduleAPI.userAgent.createRecordStates({...}) // future + +// Client-callable (server → client RPC) +moduleAPI.client.createActions({...}) // new + +// UI +moduleAPI.ui.registerRoute('/', {}, Component) +moduleAPI.ui.registerApplicationShell(Shell) +moduleAPI.ui.registerReactProvider(Provider) // future + +// Utilities (top-level) +moduleAPI.getModule('module-id') +moduleAPI.onDestroy(callback) + +// Internal (discouraged from public use) +moduleAPI._internal.statesAPI +moduleAPI._internal.deps +``` + +> Nah I think I like the full verbose way. Let's start there and get community feedback + +--- + +## 🔧 Build Transformation Updates + +### Current Detection Pattern +```typescript +// esbuild plugin looks for: +'createServerState' || 'createServerStates' || 'createServerAction' || 'createServerActions' +``` + +### Proposed Detection Pattern + +**Option A - Method name only:** +```typescript +'createServerStates' || 'createServerActions' +``` +- Simpler regex +- Works if we use explicit names + +**Option B - Namespace + method:** +```typescript +'moduleAPI.server.createServerStates' || 'moduleAPI.server.createServerActions' +``` +- More precise +- Avoids false positives + +**Option C - Any `moduleAPI.server.*`:** +```typescript +/moduleAPI\.server\./ +``` +- Future-proof +- Simple rule: "anything under `moduleAPI.server` gets stripped" + +> I do like that in general. Though I don't want to enforce someone to name their variable `moduleAPI`, so this doesn't work. certainly don't want to remove `server.` entirely from the code. let's not do this + +### Recommendation: **Option C** +Strip everything under `moduleAPI.server.*`, but leave stubs that throw errors (per Q8.2 decision). + +> No let's go with Option A + +```typescript +// Client build result +moduleAPI.server = new Proxy({}, { + get() { + throw new Error('moduleAPI.server is only available in server builds') + } +}) +``` + +> Note that this is isomorphic code. The client will indeed be calling `moduleAPI.server.createServerActions` in order to be able to call those actions. the argument to the function is stripped at compile time though. We compile out the `createServerState` so the client doesn't know about those at all + +--- + +## 📝 Additional Recommendations + +### 1. TypeScript Type Safety +Even though files are isomorphic, we can still provide better types: + +```typescript +// In client builds, server APIs could be typed as unavailable +type ModuleAPIClient = { + server: never; // TypeScript error if you try to access + shared: SharedAPI; + userAgent: UserAgentAPI; + // ... +} + +// In server builds +type ModuleAPIServer = { + server: ServerAPI; + shared: SharedAPI; + userAgent: UserAgentAPI; + // ... +} +``` + +Though you mentioned TypeScript can't help because files are isomorphic - this might still catch some mistakes during development. + +> `moduleAPI.server` does exist on the client though, so no + +### 2. JSDoc Comments +Add comprehensive JSDoc with examples: + +```typescript +/** + * Create server-only states that are never available to clients. + * + * **Security:** State values are only accessible server-side. Clients cannot + * read these values, even if they obtain a reference to the state object. + * + * **Storage:** Persisted to server storage (database/file system) + * + * **Build:** Entire variable declarations are removed from client builds. + * + * @example + * ```typescript + * const secrets = await moduleAPI.server.createServerStates({ + * apiKey: process.env.STRIPE_KEY, + * dbPassword: process.env.DB_PASSWORD + * }) + * + * // Server-side only + * const key = secrets.apiKey.getState() + * ``` + */ +createServerStates>( + states: States +): Promise<{[K in keyof States]: ServerStateSupervisor}> +``` + +### 3. Error Messages +Improve error messages to guide users: + +```typescript +// When accessing server state from client stub +Error: Cannot access server state "apiKey" from client build. +Server states are only available server-side for security. +Use moduleAPI.shared.createStates() for client-accessible state. +``` + +> Error message structure looks good + +### 4. Linting Rules (Future) +Create ESLint rules: +- Warn if hardcoded secrets appear in `moduleAPI.shared.*` or `moduleAPI.userAgent.*` +- Warn if `moduleAPI.server.*` is accessed in UI component files +- Suggest using `createRecordStates` when state is array of objects with `id` field + +> Maybe. If we're going to use eslint, let's get creative and objective. What are we actually trying to solve? We can read the react hooks eslint plugin and learn from that. "server state must be accessed in a server action or `runOnServer` callback". can we have eslint be aware of code between comments too? like this /Users/mickmister/code/jamtools-worktrees/server-state-jamtools-worktree/packages/springboard/cli/src/esbuild_plugins/esbuild_plugin_platform_inject.ts:34 +1: const platformRegex = new RegExp(`\/\/ @platform "${platform}"([\\s\\S]*?)\/\/ @platform end`, 'g'); +1: const otherPlatformRegex = new RegExp(`\/\/ @platform "(node|browser|react-native|fetch)"([\\s\\S]*?)\/\/ @platform end`, 'g'); + +> we can ensure server stuff is being done in server land. you can do things like this /Users/mickmister/code/jamtools-worktrees/server-state-jamtools-worktree/packages/jamtools/core/modules/io/io_module.tsx:41 +1: // @platform "node" +1: createIoDependencies = async () => { + +> Speaking of which, we need an easy way to do these things. Maybe a `springboard.runOn('server' | 'client' | 'browser' | 'userAgent' | 'react-native' | 'tauri' | 'node' | 'cf-worker')`. Probably better than `moduleAPI.server.runOnServer` + +> so this becomes + +```typescript +springboard.runOn('server', () => { + createIoDependencies = async () => { + + }; +}); +``` + +> `runOn` should return the return value of the callback, which may be a promise, in which case `runOn` returns a promise as well + +> what do you think about this? the comment things are hard to type and have to autocompletion hand-holding. we can compile out the code so it's not present on other platforms, allowing seamless async imports like /Users/mickmister/code/jamtools-worktrees/server-state-jamtools-worktree/packages/jamtools/core/modules/io/io_module.tsx:50 +1: const {NodeQwertyService} = await import('@jamtools/core/services/node/node_qwerty_service'); +1: const {NodeMidiService} = await import('@jamtools/core/services/node/node_midi_service'); + +--- + +## 🎯 Implementation Priority + +1. **Phase 1 - Core Refactor:** + - Implement namespaced structure + - Add explicit method names (`createServerStates`, etc.) + - Move to `_internal.*` + - Update build transformations + - Add error stubs for client builds + +2. **Phase 2 - Documentation:** + - Regenerate typedoc API reference + - Add comprehensive JSDoc + - Update quickstart guide + - Add security best practices doc + +3. **Phase 3 - New Features:** + - Add `moduleAPI.client.createActions()` for server→client RPC + - Add `createRecordStates()` across namespaces + - Add `moduleAPI.ui.registerReactProvider()` + +4. **Phase 4 - DX Improvements:** + - Add ESLint rules + - Improve error messages + - Add code snippets for VS Code + +> For eslint rules, please see https://github.com/facebook/react/blob/main/packages/eslint-plugin-react-hooks/src/rules/ExhaustiveDeps.ts which I've placed at ./configs/ExhaustiveDeps.ts + +--- + +## ❓ Questions for You + +1. **Redundancy:** Do you prefer explicit (`createSharedStates`) everywhere, or hybrid (explicit for `server.*`, short elsewhere)? + +> I think I like `createSharedStates` + +2. **Client actions namespace:** `moduleAPI.client.*` or something else? Should it be symmetric with server? + +> I think it should be `createClientAction`. it represents something to run in the caller of an action. I think RN process can reach into webview this way too. like `clientActions.toast(args, {mode: 'local'});`, so it doesn't require a user id etc. + +3. **Record-based state:** Should it follow same pattern (`createRecordStates` under each namespace)? + +> `moduleAPI.client.createStates` won't exist. because user agent state should be used instead + +4. **Object.freeze:** Which objects should be frozen? Just `moduleAPI`, or also state supervisors? + +> I think everything the framework creates. modules themselves too after they're done initializing + +5. **Docs priority:** Should we do hard break + refactor first, then docs? Or write docs to finalize API before implementing? + +> we could write docs first. maybe a very condensed version that's easy to review + +> ingest this, and create another doc to flesh out any more important things diff --git a/claude_notes/003-FINAL_API_DESIGN.md b/claude_notes/003-FINAL_API_DESIGN.md new file mode 100644 index 00000000..42d68207 --- /dev/null +++ b/claude_notes/003-FINAL_API_DESIGN.md @@ -0,0 +1,944 @@ +# Final API Design - Springboard ModuleAPI v2 + +## Executive Summary + +This document represents the finalized API design based on extensive Q&A and feedback. All decisions are marked as **✅ FINAL**. + +--- + +## ✅ Core Design Principles + +1. **Verbose & Explicit** - Method names clearly state what they create (`createServerStates`, not `createStates`) +2. **Namespace Organization** - Group related APIs under `.server`, `.shared`, `.userAgent`, `.ui` +3. **Security by Design** - Server-only code is obvious and stripped at build time +4. **Platform Flexibility** - Use `springboard.runOn(platform, callback)` for platform-specific code +5. **Immutability** - Freeze all framework-created objects after initialization + +--- + +## ✅ Complete API Surface + +```typescript +// ============================================ +// SPRINGBOARD GLOBAL API +// ============================================ + +springboard.registerModule(moduleId, options, callback) +springboard.registerClassModule(callback) +springboard.registerSplashScreen(Component) +springboard.runOn(platform, callback) // NEW + +// ============================================ +// MODULE API - Server Namespace +// ============================================ + +moduleAPI.server.createServerStates({...}) +moduleAPI.server.createServerActions({...}) +moduleAPI.server.createServerRecordStates({...}) + +// ============================================ +// MODULE API - Shared Namespace +// ============================================ + +moduleAPI.shared.createSharedStates({...}) +moduleAPI.shared.createSharedActions({...}) +moduleAPI.shared.createSharedRecordStates({...}) + +// ============================================ +// MODULE API - UserAgent Namespace +// ============================================ + +moduleAPI.userAgent.createUserAgentStates({...}) +moduleAPI.userAgent.createUserAgentActions({...}) +moduleAPI.userAgent.createUserAgentRecordStates({...}) + +// ============================================ +// MODULE API - Client Namespace +// ============================================ + +moduleAPI.client.createClientActions({...}) // Server→Client RPC + +// Note: NO moduleAPI.client.createStates() - use userAgent instead + +// ============================================ +// MODULE API - UI Namespace +// ============================================ + +moduleAPI.ui.registerRoute(path, options, component) +moduleAPI.ui.registerApplicationShell(component) +moduleAPI.ui.registerReactProvider(provider) // Future + +// ============================================ +// MODULE API - Top Level Utilities +// ============================================ + +moduleAPI.getModule(moduleId) +moduleAPI.onDestroy(callback) +moduleAPI.moduleId +moduleAPI.fullPrefix + +// ============================================ +// MODULE API - Internal (Discouraged) +// ============================================ + +moduleAPI._internal.statesAPI +moduleAPI._internal.deps +``` + +--- + +## 🆕 New Feature: `springboard.runOn(platform, callback)` + +Replace comment-based platform annotations with a typed API. + +### API Signature + +```typescript +function runOn( + platform: 'server' | 'client' | 'browser' | 'userAgent' | 'react-native' | 'tauri' | 'node' | 'cf-worker', + callback: () => T | Promise +): T | Promise +``` + +### Current Pattern (Comments) +```typescript +// @platform "node" +createIoDependencies = async () => { + const {NodeQwertyService} = await import('@jamtools/core/services/node/node_qwerty_service'); + const {NodeMidiService} = await import('@jamtools/core/services/node/node_midi_service'); + return {qwerty: new NodeQwertyService(), midi: new NodeMidiService()}; +}; +// @platform end +``` + +### New Pattern (Typed API) +```typescript +const createIoDependencies = springboard.runOn('node', async () => { + const {NodeQwertyService} = await import('@jamtools/core/services/node/node_qwerty_service'); + const {NodeMidiService} = await import('@jamtools/core/services/node/node_midi_service'); + return {qwerty: new NodeQwertyService(), midi: new NodeMidiService()}; +}); +``` + +### Benefits +- ✅ TypeScript autocomplete for platform names +- ✅ Type-safe return values +- ✅ Clear scoping of platform-specific code +- ✅ Easier to write ESLint rules for +- ✅ Works with async imports seamlessly + +### Build Transformation +The compiler detects `springboard.runOn(platform, ...)` and: +1. **On matching platform:** Replaces with just the callback body +2. **On other platforms:** Removes entire expression or replaces with `undefined` + +```typescript +// Source +const deps = springboard.runOn('node', () => createNodeDeps()); + +// Browser build +const deps = undefined; + +// Node build +const deps = (() => createNodeDeps())(); +``` + +--- + +## 🔄 Client Actions - Server→Client RPC + +Client actions allow the server to invoke functions on specific clients or broadcast to all clients. + +### Complete Example + +```typescript +springboard.registerModule('Main', {}, async (moduleAPI) => { + + // Define actions that server can call on clients + const clientActions = moduleAPI.client.createClientActions({ + toast: async (args: { + message: string; + id?: string; + type?: 'info' | 'success' | 'error' | 'warning'; + duration?: number; + }) => { + // Runs on client when server invokes it + const newId = await createOrUpdateToast(args); + return {toastId: newId}; + }, + + updateProgress: async (args: { + operationId: string; + progress: number; + status: string; + }) => { + // Update client UI + setProgress(args.operationId, args.progress); + }, + }); + + // Server actions can invoke client actions + const serverActions = moduleAPI.server.createServerActions({ + doTheThing: async (args: {userInput: string}, userContext) => { + // userContext provided by framework: + // - connectId: string + // - userId?: string + // - sessionId: string + // - metadata: Record + + // Call client action on specific user + const {toastId} = await clientActions.toast( + {message: 'Starting operation', type: 'info'}, + userContext + ); + + // Perform long-running work + const result = await doSomeWork(args).onProgress(progress => { + // Update the same toast with progress + clientActions.toast( + { + message: `Operation ${progress}% complete`, + id: toastId, + type: 'info' + }, + userContext + ); + }); + + // Final success toast + await clientActions.toast( + { + message: 'Operation complete!', + id: toastId, + type: 'success', + duration: 3000 + }, + userContext + ); + + return result; + }, + }); +}); +``` + +### Client Action Call Modes + +```typescript +// Call on specific client (default) +await clientActions.toast({message: 'Hello'}, userContext); + +// Call locally (React Native → WebView communication) +await clientActions.toast({message: 'Hello'}, {mode: 'local'}); + +// Broadcast to all connected clients +await clientActions.toast({message: 'System announcement'}, {mode: 'broadcast'}); + +// Broadcast to all EXCEPT current user +await clientActions.toast({message: 'Someone else did something'}, { + mode: 'broadcast_exclude_current_user', + userContext +}); +``` + +### Integration Example: Mantine Notifications + +```typescript +const clientActions = moduleAPI.client.createClientActions({ + showNotification: async (args: { + title?: string; + message: string; + color?: string; + icon?: React.ReactNode; + autoClose?: number | false; + id?: string; + }) => { + // Direct integration with Mantine + if (args.id) { + notifications.update({ + id: args.id, + ...args, + }); + } else { + const id = notifications.show(args); + return {notificationId: id}; + } + }, +}); +``` + +### React Native Considerations + +- **Client Actions** run in **WebView process** by default +- To run in RN process, call a UserAgent action from the client action: + +```typescript +const userAgentActions = moduleAPI.userAgent.createUserAgentActions({ + vibrate: async (args: {duration: number}) => { + // Runs in React Native process + Vibration.vibrate(args.duration); + }, +}); + +const clientActions = moduleAPI.client.createClientActions({ + notifyWithVibration: async (args: {message: string}) => { + // This runs in WebView + showToast(args.message); + + // Call to RN process + await userAgentActions.vibrate({duration: 200}); + }, +}); +``` + +--- + +## 📦 Record States - ID-Based Collections + +For managing collections of entities with IDs. + +### API + +```typescript +const recordStates = await moduleAPI.shared.createSharedRecordStates({ + users: { + initialRecords: [ + {id: '1', name: 'Alice', role: 'admin'}, + {id: '2', name: 'Bob', role: 'user'} + ], + version: 1, + migrate: async (current: {state: any, version: number}) => { + // Optional migration logic + switch (current.version) { + case 1: + // Already latest version + return current.state; + default: + throw new Error(`Unknown version: ${current.version}`); + } + } + } +}); + +// Methods available on recordStates.users: +recordStates.users.add({id: '3', name: 'Charlie', role: 'user'}) +recordStates.users.upsert({id: '1', name: 'Alice Updated'}) // Create or update +recordStates.users.update('1', {name: 'Alice Updated'}) // Update existing +recordStates.users.replace('1', {id: '1', name: 'Alice', role: 'admin'}) // Full replace +recordStates.users.remove('2') +recordStates.users.getById('1') +recordStates.users.getAll() +recordStates.users.useState() // React hook +recordStates.users.useById('1') // React hook for single record +``` + +### Migration Pattern + +```typescript +const serverStates = await moduleAPI.server.createServerRecordStates({ + myState: { + initialRecords: [], + version: 3, + migrate: async (current: {state: any, version: number}) => { + let migrated = current.state; + let fromVersion = current.version; + + // Sequential migrations + if (fromVersion < 2) { + migrated = migrateV1ToV2(migrated); + } + if (fromVersion < 3) { + migrated = migrateV2ToV3(migrated); + } + + return migrated; + }, + }, +}); + +// Run migration on server startup +await springboard.runOn('server', async () => { + await serverStates.myState.runMigration(); +}); +``` + +### Naming Consideration + +Pattern becomes: `moduleAPI.server.createServerRecordStates(...)` + +This is verbose but: +- ✅ Explicit about what's being created +- ✅ Compiler can detect method name for stripping +- ✅ Clear during code review +- ✅ Consistent with decision to be verbose everywhere + +--- + +## 🔨 Build Transformation Strategy + +### Detection Method: **Option A - Method Name** + +The compiler looks for specific method names: +- `createServerStates` +- `createServerActions` +- `createServerRecordStates` + +**Reason:** Don't enforce variable naming (`moduleAPI` could be renamed to `m` or `api`) + +### Transformation Rules + +#### 1. Server States - Variable Removal +```typescript +// Source +const serverStates = await moduleAPI.server.createServerStates({ + apiKey: process.env.STRIPE_KEY, + dbPassword: process.env.DB_PASSWORD +}); + +// Client build +// (entire declaration removed) + +// Server build +const serverStates = await moduleAPI.server.createServerStates({...}); +``` + +#### 2. Server Actions - Body Stripping +```typescript +// Source +const serverActions = moduleAPI.server.createServerActions({ + authenticate: async (args) => { + const session = serverStates.apiKey.getState(); + console.log('Authenticating:', args); + return {authenticated: true}; + } +}); + +// Client build +const serverActions = moduleAPI.server.createServerActions({ + authenticate: async () => {} +}); + +// Server build +const serverActions = moduleAPI.server.createServerActions({...}); +``` + +#### 3. Platform-Specific Code +```typescript +// Source +const deps = springboard.runOn('node', () => createNodeDeps()); + +// Client build +const deps = undefined; + +// Server build +const deps = (() => createNodeDeps())(); +``` + +### Important Note: Isomorphic Code + +`moduleAPI.server` **DOES** exist on the client: +- Client needs to call server actions via RPC +- Only the **state variable declarations** are removed +- Action **implementations** have bodies stripped but structure remains + +```typescript +// Client sees: +moduleAPI.server.createServerActions({ + myAction: async () => {} // Body stripped, RPC call injected +}) + +// Client does NOT see: +const serverStates = ... // Entire variable removed +``` + +--- + +## 🔒 Object Freezing Strategy + +Freeze everything the framework creates to prevent accidental mutation. + +### What Gets Frozen + +```typescript +// 1. ModuleAPI instance +Object.freeze(moduleAPI); +Object.freeze(moduleAPI.server); +Object.freeze(moduleAPI.shared); +Object.freeze(moduleAPI.userAgent); +Object.freeze(moduleAPI.client); +Object.freeze(moduleAPI.ui); +Object.freeze(moduleAPI._internal); + +// 2. State supervisors +const states = await moduleAPI.shared.createSharedStates({...}); +Object.freeze(states); +Object.freeze(states.someState); // Freeze each supervisor + +// 3. Action objects +const actions = moduleAPI.shared.createSharedActions({...}); +Object.freeze(actions); + +// 4. Modules after initialization +const module = await registerModule('MyModule', {}, async (api) => { + return {someExport: 'value'}; +}); +Object.freeze(module); +``` + +### Implementation + +Use deep freeze utility: +```typescript +function deepFreeze(obj: T): T { + Object.freeze(obj); + Object.getOwnPropertyNames(obj).forEach(prop => { + const val = (obj as any)[prop]; + if (val && typeof val === 'object' && !Object.isFrozen(val)) { + deepFreeze(val); + } + }); + return obj; +} +``` + +--- + +## 🧪 ESLint Rules + +Inspired by React's exhaustive deps rule, create rules for Springboard patterns. + +### Rule 1: `springboard/server-state-access` + +**Enforce:** Server states can only be accessed in server actions or `springboard.runOn('server', ...)` + +```typescript +// ❌ Bad +const MyComponent = () => { + const value = serverStates.apiKey.getState(); // ERROR + return
{value}
; +}; + +// ✅ Good +const serverActions = moduleAPI.server.createServerActions({ + getApiKey: async () => { + return serverStates.apiKey.getState(); // OK - inside server action + } +}); +``` + +### Rule 2: `springboard/no-secrets-in-shared` + +**Enforce:** Warn on hardcoded secrets in shared/userAgent state + +```typescript +// ❌ Bad +const states = await moduleAPI.shared.createSharedStates({ + apiKey: 'sk_live_12345' // ERROR: Possible secret in shared state +}); + +// ✅ Good +const states = await moduleAPI.server.createServerStates({ + apiKey: process.env.STRIPE_KEY // OK - server state +}); +``` + +### Rule 3: `springboard/platform-awareness` + +**Enforce:** Respect `@platform` comments and `springboard.runOn()` blocks + +```typescript +// ❌ Bad +const browserFeature = springboard.runOn('browser', () => { + return window.localStorage; // OK +}); + +const oops = window.localStorage; // ERROR if not in browser block +``` + +### Rule 4: `springboard/client-action-context` + +**Enforce:** Client actions invoked from server must have userContext or mode + +```typescript +// ❌ Bad +await clientActions.toast({message: 'hi'}); // ERROR: Missing context + +// ✅ Good +await clientActions.toast({message: 'hi'}, userContext); +await clientActions.toast({message: 'hi'}, {mode: 'broadcast'}); +``` + +### Implementation Notes + +- Study `configs/ExhaustiveDeps.ts` for AST traversal patterns +- Use ESTree node types: `CallExpression`, `MemberExpression`, `Identifier` +- Track scope and variable declarations +- Support both `// @platform` comments and `springboard.runOn()` patterns + +--- + +## 📚 Documentation Structure + +Write condensed docs first for review, then expand. + +### Condensed Doc Outline + +1. **API Reference** (Auto-generated via TypeDoc) + - `moduleAPI.server.*` + - `moduleAPI.shared.*` + - `moduleAPI.userAgent.*` + - `moduleAPI.client.*` + - `moduleAPI.ui.*` + - `springboard.runOn()` + +2. **Core Concepts** (1-2 pages each) + - State Lifecycle & Sync + - Server vs Shared vs UserAgent + - Client Actions Pattern + - Platform-Specific Code + - Record States & Migrations + +3. **Guides** (Short, practical) + - Quickstart (update existing) + - Creating Server-Only Logic + - Building Multiplayer Features + - Integrating with UI Libraries (Mantine example) + - Mobile App Considerations (RN + WebView) + +4. **Migration from v1** (Not needed per your note, but keep for reference) + +5. **Security Best Practices** + - Never put secrets in `shared` or `userAgent` + - Always use `server` for sensitive data + - Validate inputs in server actions + - Use client actions for user notifications + +--- + +## 🎯 Implementation Checklist + +### Phase 1: Core Refactor +- [ ] Create namespace classes (ServerAPI, SharedAPI, UserAgentAPI, ClientAPI, UIAPI) +- [ ] Implement verbose method names everywhere +- [ ] Move existing APIs to `_internal.*` +- [ ] Add Object.freeze() to all framework objects +- [ ] Update existing modules to use new API +- [ ] Add JSDoc comments to all public APIs + +### Phase 2: Build System +- [ ] Update esbuild plugin to detect new method names +- [ ] Implement `springboard.runOn()` transformation +- [ ] Strip server state variables completely +- [ ] Strip server action bodies (keep structure) +- [ ] Add error stubs for client builds +- [ ] Test transformation with all edge cases + +### Phase 3: Client Actions +- [ ] Implement `createClientActions()` method +- [ ] Add userContext parameter to RPC framework +- [ ] Support call modes: `local`, `broadcast`, `broadcast_exclude_current_user` +- [ ] Add client action registry +- [ ] Implement server→client invocation +- [ ] Test React Native → WebView communication + +### Phase 4: Record States +- [ ] Implement `createRecordStates()` for all namespaces +- [ ] Add methods: `add`, `update`, `upsert`, `replace`, `remove`, `getById`, `getAll` +- [ ] Implement migration system with version tracking +- [ ] Add React hooks: `useState()`, `useById()` +- [ ] Test migration patterns + +### Phase 5: Developer Tools +- [ ] Write ESLint rules (4 rules listed above) +- [ ] Add VS Code snippets for common patterns +- [ ] Improve error messages with helpful suggestions +- [ ] Add TypeScript declaration files + +### Phase 6: Documentation +- [ ] Write condensed API docs (review draft) +- [ ] Generate TypeDoc reference +- [ ] Update quickstart guide +- [ ] Write security best practices +- [ ] Add example integrations (Mantine, etc.) +- [ ] Solicit community feedback + +--- + +## ❓ Remaining Questions & Considerations + +### 1. UserAgent vs Client Terminology + +**Current decision:** +- `userAgent` = device storage (localStorage, RN AsyncStorage) +- `client` = network participant (for server→client RPC) + +**Consideration:** Is this distinction clear to newcomers? Should we rename? +- Alternative: `device` and `client`? +- Alternative: `local` and `client`? + +> someone could use `local` and think it's local to the server. I named `userAgent` before I was thinking of making server state etc., which is why I wanted to differentiate where it's stored. I like userAgent. should it be called local instead? the server actually does have its own userAgent state, but that's essentially the same as server state, but it's not removed by the compiler, and generally not accessed in any meaningful way on the server + +### 2. Client Action Return Values + +When server calls client action: +```typescript +const {toastId} = await clientActions.toast({message: 'hi'}, userContext); +``` + +**Questions:** +- Should this wait for client response? (adds latency) +- Or fire-and-forget with optional callback? +- What happens if client is disconnected? +- Should there be a timeout? + +> I like supporting both async and optional callback. have a timeout as well. but return `{error: 'timed out' (or similar)} instead of throwing an error. maybe the server can supply its own toastId so it doesn't need to wait for the client to return that. it makes its own uuid that it keeps track of in the moment. client actions should generally not expect a return value. should be stateless but resuamable like the notification id thing + +**Suggested approach:** +- Default: Fire-and-forget (don't await) +- Optional: `await` if you need return value +- Timeout after 5 seconds with error +- Queue messages if client temporarily disconnected + +### 3. Record State Primary Keys + +**Current:** Require `id` field + +**Questions:** +- Always string? Or allow number? +- Support composite keys? +- Support custom key function? + +> always string +> just do id for now. it makes it so we can have a column in the database to search for. we're doing a basic sql kvstore atm /Users/mickmister/code/jamtools-worktrees/server-state-jamtools-worktree/packages/springboard/data_storage/kv_api_trpc.ts:3 +1: export class HttpKvStoreFromKysely { + /Users/mickmister/code/jamtools-worktrees/server-state-jamtools-worktree/packages/springboard/data_storage/sqlite_db.ts:25 +1: await db.schema.createTable('kvstore') +> so we'll make a record based kvstore table that has ids and uses a key just like this kvstore table + +**Suggested:** Start simple with string `id`, expand later if needed + +### 4. Migration Rollback + +**Question:** Should migrations support rollback/undo? + +```typescript +migrate: async (current) => { + return { + up: () => migrateForward(current), + down: () => migrateBackward(current) + }; +} +``` + +> Nah + +**Suggested:** Not initially. Add if users request it. + +### 5. Platform Detection at Runtime + +**Question:** Should we expose `springboard.platform` for runtime checks? + +```typescript +if (springboard.platform === 'node') { + // Do something +} +``` + +> yeah but it's a combination of a few things +- "node" and "server" +- "cf-workers" and "server" +- "web" and "browser" and "client" and "user-agent" +- "tauri" and "browser" and "client" and "user-agent" +- "react-native-web" and "browser" and "client" +- "react-native" and "user-agent" (idk about client. I think react-native should forward client actions to the webview, for times where the RN process is communicating to the server, but the websocket is going to RN. idk maybe both are connected with the same better-auth session token, so they ) + +> I feel like the most common ones will be "server" and "browser", which is the same as "server" and "client" looking at the thing. maybe we can have a helper function like `isPlatform` that can do multiple checks for us, just like `runOn` for the same sets as above + +**Suggested:** Yes, useful for debugging and conditional logic that can't be compiled out + +### 6. State Versioning Strategy + +**Question:** Where should version be stored? + +> It should be stored in the database behind the scenes. it should be optional, as well as migrate + +**Option A:** In state key +```typescript +const state = await moduleAPI.shared.createSharedStates({ + 'myState:v3': initialValue +}); +``` + +**Option B:** In metadata +```typescript +const state = await moduleAPI.shared.createSharedStates({ + myState: {value: initialValue, version: 3} +}); +``` + +> I don't like how verbose this is, but it seems right. but I hate how it's verbose. maybe we make it so if a version is specified then it becomes a versioned object. but if not, the value it the value that `myState` points to. so we'll do a typescript ternary and make the thing a special versioned thing if it has a `version` key, and then support the optional `migrate` func as well. should be a smooth transition and not require any special setup between those two things. we should just add the version number to a separate thing in the database. then these are both valid: + +```ts +const state = await moduleAPI.shared.createSharedStates({ + myState: {value: {message: null} as ({message: string | null}), version: 3}, +}); + +const state = await moduleAPI.shared.createSharedStates({ + myState: {message: null} as {message: string | null}, +}); + +``` + +> and the system adapts when version is added at a later deployment. maybe we enforce a `migrate` func if version is provided. we can put this in jsdocs + +**Option C:** Separate version store +```typescript +// Framework tracks versions separately +``` + +**Suggested:** Option B for record states, Option C for regular states + +> it's its own row in the existing kvstore behind the scenes for both of these things + +--- + +## 🎨 Code Examples - Before & After + +### Example 1: Simple Module + +**Before:** +```typescript +springboard.registerModule('TicTacToe', {}, async (moduleAPI) => { + const boardState = await moduleAPI.statesAPI.createSharedState('board', initialBoard); + + const actions = moduleAPI.createActions({ + clickCell: async (args) => { + boardState.setState(newBoard); + } + }); + + moduleAPI.registerRoute('/', {}, () => ); +}); +``` + +**After:** +```typescript +springboard.registerModule('TicTacToe', {}, async (moduleAPI) => { + const states = await moduleAPI.shared.createSharedStates({ + board: initialBoard, + winner: null + }); + + const actions = moduleAPI.shared.createSharedActions({ + clickCell: async (args) => { + states.board.setState(newBoard); + } + }); + + moduleAPI.ui.registerRoute('/', {}, () => ); +}); +``` + +### Example 2: Server + Client Module + +**Before:** +```typescript +springboard.registerModule('Auth', {}, async (moduleAPI) => { + const serverState = await moduleAPI.statesAPI.createServerState('sessions', {}); + + const serverAction = moduleAPI.createServerAction('login', {}, async (creds) => { + const session = await validateCredentials(creds); + serverState.setState({...serverState.getState(), [session.id]: session}); + return {token: session.token}; + }); + + moduleAPI.registerRoute('/login', {}, () => ); +}); +``` + +**After:** +```typescript +springboard.registerModule('Auth', {}, async (moduleAPI) => { + const serverStates = await moduleAPI.server.createServerStates({ + sessions: {} + }); + + const clientActions = moduleAPI.client.createClientActions({ + showWelcome: async (args: {username: string}) => { + notifications.show({message: `Welcome ${args.username}!`}); + } + }); + + const serverActions = moduleAPI.server.createServerActions({ + login: async (creds, userContext) => { + const session = await validateCredentials(creds); + serverStates.sessions.setStateImmer(s => { + s[session.id] = session; + }); + + // Notify client + await clientActions.showWelcome({username: session.username}, userContext); + + return {token: session.token}; + } + }); + + moduleAPI.ui.registerRoute('/login', {}, () => ); +}); +``` + +### Example 3: Platform-Specific Code + +**Before:** +```typescript +// @platform "node" +const createNodeDeps = async () => { + const midi = await import('midi-service'); + return {midi}; +}; +// @platform end + +// @platform "browser" +const createBrowserDeps = async () => { + const audio = await import('web-audio'); + return {audio}; +}; +// @platform end +``` + +**After:** +```typescript +const deps = await springboard.runOn('node', async () => { + const midi = await import('midi-service'); + return {midi}; +}) ?? await springboard.runOn('browser', async () => { + const audio = await import('web-audio'); + return {audio}; +}); +``` + +> this looks nuts. definitely want unit tests on our esbuild plugin. really really cool stuff here. note we also need to support babel. we can copy this one and make unit tests for it too. it should match up with esbuild ideally /Users/mickmister/code/songdrive-workspaces/ffmpeg-songdrive/apps/mobile/babel.config.js. it's already got the comments thing + +--- + +## 🚀 Next Steps + +1. **Review this document** - Confirm all decisions are correct +2. **Write condensed docs** - API reference + core concepts (10-20 pages total) +3. **Get feedback** - Share with 2-3 developers for initial reactions +4. **Implement Phase 1** - Core refactor with new API structure +5. **Test thoroughly** - Ensure build transformations work correctly +6. **Implement remaining phases** - Client actions, record states, ESLint +7. **Launch & iterate** - Gather community feedback, adjust as needed + +--- + +## 📝 Notes for Implementation + +- Keep existing code working during development (feature flags?) +- Write tests for build transformations before implementing +- Consider adding `--verbose` flag for API naming (community feedback mechanism) +- Document security model clearly (what gets stripped, what doesn't) +- Add migration guides only if users request (not initially needed) +- Freeze objects gradually - start with ModuleAPI, expand to states +- Make ESLint rules opt-in initially (gradual adoption) diff --git a/claude_notes/004-CLARIFYING_QUESTIONS.md b/claude_notes/004-CLARIFYING_QUESTIONS.md new file mode 100644 index 00000000..00c06591 --- /dev/null +++ b/claude_notes/004-CLARIFYING_QUESTIONS.md @@ -0,0 +1,567 @@ +# Clarifying Questions - API Design Details + +Based on your feedback, here are some implementation details that need clarification. + +--- + +## 1. Platform Detection - `isPlatform()` API + +You mentioned wanting a helper similar to `runOn` that checks multiple platform tags: + +### Pattern Matching Logic + +```typescript +// These are the platform combinations you listed: +const combinations = { + SERVER_NODE: ['node', 'server'], + SERVER_WORKERS: ['cf-workers', 'server'], + BROWSER_WEB: ['web', 'browser', 'client', 'user-agent'], + BROWSER_TAURI: ['tauri', 'browser', 'client', 'user-agent'], + BROWSER_RN_WEB: ['react-native-web', 'browser', 'client'], + RN_NATIVE: ['react-native', 'user-agent'], +}; +``` + +**Question 1a:** Should `isPlatform(['server', 'node'])` return true if ANY match (OR logic) or ALL match (AND logic)? + +```typescript +// Option A: ANY (OR) +isPlatform(['server', 'node']) // true on node, true on server + +// Option B: ALL (AND) +isPlatform(['server', 'node']) // true only if BOTH node AND server +``` + +> let's just allow one arg + +**Question 1b:** Should we provide predefined constants? + +```typescript +// Option A: String arrays +isPlatform(['server', 'node']) + +// Option B: Constants +isPlatform(PLATFORM.SERVER_NODE) + +// Option C: Both +isPlatform(['server', 'node']) // works +isPlatform(PLATFORM.SERVER_NODE) // also works +``` + +> keep them separate. we can have a `as const` thing and have the separated things + +**Question 1c:** How does this work at compile time vs runtime? + +```typescript +// At compile time (can optimize away branches) +if (isPlatform(['browser'])) { + // This code can be removed in server build +} + +// At runtime (can't optimize) +const platforms = getPlatformsFromConfig(); +if (isPlatform(platforms)) { + // Dynamic check, can't be compiled out +} +``` + +Should `isPlatform()` work at both compile and runtime, or just runtime? + +> runtime use only. no compile changes + +--- + +## 2. Client Action Patterns + +### Timeout Behavior + +You want `{error: 'timed out'}` instead of throwing. + +**Question 2a:** What's the default timeout? + +```typescript +// Option A: Fixed default +const DEFAULT_CLIENT_ACTION_TIMEOUT = 5000; // 5 seconds + +// Option B: Configurable per action +moduleAPI.client.createClientActions({ + toast: { + handler: async (args) => { /* ... */ }, + timeout: 10000 // 10 seconds for this specific action + } +}); + +// Option C: Configurable per call +await clientActions.toast({message: 'hi'}, userContext, {timeout: 3000}); +``` + +> I like all of them. cascade CBA. If C is defined, do that, otherwise do B, otherwise A +> we should still accept functions as the values passed to `createClientActions` + +**Question 2b:** What does "optional callback" mean alongside async/await? + +```typescript +// Option A: Promise + optional callback (Node.js style) +await clientActions.toast({message: 'hi'}, userContext, (error, result) => { + // Called when response received or timeout +}); + +// Option B: Fire-and-forget mode +clientActions.toast({message: 'hi'}, userContext); // No await, returns void + +// Option C: Both patterns +const promise = clientActions.toast({message: 'hi'}, userContext); +await promise; // Can await if needed +// Or ignore promise if fire-and-forget +``` + +> maybe we can just do a not awaited `then` if we want to do an "optional callback". any differences in performance or anything? + +**Question 2c:** Server-generated IDs pattern + +You mentioned server can supply its own UUID (like `toastId`) to avoid waiting. Should this be a convention or enforced? + +> it's an arbitrary function call with an argument. nothing special to me + +```typescript +// Convention: Pass id in args +const toastId = uuid(); +await clientActions.toast({message: 'Starting', id: toastId}, userContext); +// ... later +await clientActions.toast({message: 'Progress 50%', id: toastId}, userContext); + +// Or: Framework provides id automatically? +const {operationId} = clientActions.toast.createOperation(); +await clientActions.toast({message: 'Starting', operationId}, userContext); +``` + +### Stateless but Resumable + +You said "stateless but resumable like the notification id thing." + +**Question 2d:** What does "resumable" mean here? + +```typescript +// Is it about idempotency? +// Calling multiple times with same id updates the same toast +await clientActions.toast({message: 'Step 1', id: 'op-123'}, userContext); +await clientActions.toast({message: 'Step 2', id: 'op-123'}, userContext); // Updates same toast + +// Or about reconnection handling? +// If client disconnects and reconnects, can continue operation +await clientActions.toast({message: 'Step 3', id: 'op-123'}, userContext); // Works after reconnect + +// Or both? +``` + +> the toast function itself is written to be resumable. just commenting that we get that for free because of mantine's upsert notification thing. mantine is not part of the framework, just using that as an example + +--- + +## 3. UserAgent State on Server + +You mentioned: "the server actually does have its own userAgent state, but that's essentially the same as server state, but it's not removed by the compiler, and generally not accessed in any meaningful way on the server" + +**Question 3a:** Should we document this edge case or hide it? + +```typescript +// Should this be allowed? +await springboard.runOn('server', async () => { + const userAgentState = await moduleAPI.userAgent.createUserAgentStates({ + something: 'value' + }); + + // What storage does this use on server? + // Is it useful for anything? +}); +``` + +**Question 3b:** Should we warn against or prevent this pattern? + +```typescript +// Option A: Allow silently (current behavior) + +// Option B: Runtime warning +console.warn('UserAgent state on server is rarely needed. Consider using server state instead.'); + +// Option C: Compile-time error via ESLint +// Rule: springboard/no-useragent-state-on-server +``` + +> let's do it when the user agent state is *accessed*, not created. and give advice on how to run that check only in the client instead + +--- + +## 4. Babel Transformer Implementation + +You mentioned needing to support both esbuild and babel, referencing an existing babel config with comments. + +**Question 4a:** Implementation order? + +- Implement esbuild first, then port to babel? +- Implement babel first (since it exists)? +- Implement both simultaneously? + +> implement esbuild first. we want to focus on the new API + +**Question 4b:** Should they share test suites? + +```typescript +// Shared test cases +const testCases = [ + { + input: 'moduleAPI.server.createServerStates({...})', + expectedOutput: '/* removed */', + }, + // ... more cases +]; + +// Run against both transformers +describe('esbuild plugin', () => { + testCases.forEach(testCase => { + it(testCase.input, () => { + expect(esbuildTransform(testCase.input)).toBe(testCase.expectedOutput); + }); + }); +}); + +describe('babel plugin', () => { + testCases.forEach(testCase => { + it(testCase.input, () => { + expect(babelTransform(testCase.input)).toBe(testCase.expectedOutput); + }); + }); +}); +``` + +> yeah that sounds good + +**Question 4c:** The existing babel config at `/Users/mickmister/code/songdrive-workspaces/ffmpeg-songdrive/apps/mobile/babel.config.js` already has the comments thing. Should we: +- Extend that plugin? +- Create a new plugin? +- Merge functionality? + +> extend it and add the new stuff. probably want to share some things since the esbuild one is using babel for part of it too. maybe we could have one implementation, unless if babel is not efficient for the parts we're not using babel for + +--- + +## 5. State Versioning Implementation + +You want smooth transition between versioned and non-versioned state: + +```typescript +// Non-versioned +const state = await moduleAPI.shared.createSharedStates({ + myState: {message: null} +}); + +// Later, add version +const state = await moduleAPI.shared.createSharedStates({ + myState: {value: {message: null}, version: 3} +}); +``` + +**Question 5a:** When version is added for the first time, what version was the old data? + +```typescript +// Day 1: No version +myState: {message: 'hello'} + +// Day 2: Add version, what happens? +myState: {value: {message: null}, version: 2, migrate: async (current) => { + // What is current.version here? + // Was the old data implicitly version 1? Or version 0? Or undefined? +}} +``` + +> the version is null to begin with. any defined version is greater than that version + +**Question 5b:** Should we enforce `migrate` when version is provided? + +```typescript +// Option A: Required +myState: { + value: initialValue, + version: 2, + migrate: async (current) => { /* REQUIRED */ } +} + +// Option B: Optional (but warn in JSDoc) +myState: { + value: initialValue, + version: 2, + // migrate is optional, but if you have multiple versions, you should provide it +} +``` + +> yes enforce migrate + +**Question 5c:** Database schema for version tracking + +You said "it's its own row in the existing kvstore behind the scenes." Should version be: + +```typescript +// Option A: Suffix on key +key: 'prefix|state|myState' +value: {message: 'hello'} +key: 'prefix|state|myState|__version' +value: 3 + +// Option B: Metadata column +key: 'prefix|state|myState' +value: {message: 'hello'} +metadata: {version: 3} + +// Option C: Separate table +// kvstore table: stores values +// kvstore_versions table: stores versions +``` + +> metadata sounds good + +--- + +## 6. Record States Database Schema + +You mentioned making "a record based kvstore table that has ids and uses a key just like this kvstore table." + +**Question 6a:** Separate table or same table? + +```typescript +// Option A: Separate table +// kvstore: regular states +// kvstore_records: record states (with id column) + +// Option B: Same table with flag +// kvstore: { key, value, is_record, record_id } + +// Option C: Same table, different key pattern +// kvstore: +// - key='prefix|state|users' -> full array +// - key='prefix|state|users|record|123' -> individual record +``` + +> let's do a new table for conventional separation and to add the id column + +**Question 6b:** Sync behavior for record states + +```typescript +// When record is updated, should we: + +// Option A: Sync entire collection every time +users.update('123', {name: 'New name'}); +// -> Broadcasts: {type: 'recordState', key: 'users', value: allUsers} + +// Option B: Sync individual record +users.update('123', {name: 'New name'}); +// -> Broadcasts: {type: 'recordUpdate', key: 'users', recordId: '123', value: updatedUser} + +// Option C: Batch sync +users.update('123', {name: 'New name'}); +users.update('456', {name: 'Another'}); +// -> Broadcasts once: {type: 'recordUpdates', key: 'users', updates: [{id: '123', ...}, {id: '456', ...}]} +``` + +> sync individual record + +**Question 6c:** Performance for large collections + +```typescript +const users = await moduleAPI.shared.createSharedRecordStates({ + users: {initialRecords: []} // Could grow to 10,000+ records +}); + +// Should we: +// Option A: Load all records into memory (current pattern for states) +// Option B: Lazy load records on demand +// Option C: Pagination/windowing support +// Option D: Warn if collection exceeds threshold +``` + +> we'll address that when we need to +> maybe we can have a `avoidPreload: boolean` to avoid loading all of them + +--- + +## 7. Platform-Specific Code Edge Cases + +For the nullish coalescing pattern you called "nuts": + +```typescript +const deps = await springboard.runOn('node', async () => { + return {midi: await import('midi-service')}; +}) ?? await springboard.runOn('browser', async () => { + return {audio: await import('web-audio')}; +}); +``` + +**Question 7a:** What should this compile to? + +```typescript +// In node build: +const deps = await (async () => { + return {midi: await import('midi-service')}; +})(); +// The browser part is removed entirely + +// In browser build: +const deps = await (async () => { + return {audio: await import('web-audio')}; +})(); +// The node part is removed entirely + +// But what about the ?? operator? Should we: +// Option A: Remove the whole ?? chain, just keep the matching branch +// Option B: Keep structure but replace non-matching with undefined +// Option C: Detect pattern and optimize +``` + +> I think we should just remove the function body of the callback for non-matching platforms during compile time, and then just return `null` at runtime +> we should document the matrix of `runOn` in the docs + +**Question 7b:** Should we support complex boolean logic? + +```typescript +// Should these work? +const a = springboard.runOn('node', () => 1) || springboard.runOn('browser', () => 2); +const b = springboard.runOn('node', () => 1) && springboard.runOn('browser', () => 2); +const c = springboard.runOn('node', () => 1) ? 'yes' : 'no'; + +// Or only simple patterns? +``` + +> that should already work with my idea above + +--- + +## 8. ESLint Rule: Platform Awareness + +For the rule that respects `@platform` comments and `springboard.runOn()`: + +**Question 8a:** Should the rule understand both old and new syntax? + +```typescript +// Old syntax (comments) +// @platform "node" +const midi = require('midi'); +// @platform end + +// New syntax (runOn) +const midi = springboard.runOn('node', () => require('midi')); + +// Should ESLint handle both? Or migrate to new syntax? +``` + +> hopefully both yeah + +**Question 8b:** What about nested platform blocks? + +```typescript +springboard.runOn('server', () => { + springboard.runOn('node', () => { + // Only on server + node (not cf-workers) + }); +}); + +// Should this be: +// Option A: Allowed (AND logic) +// Option B: Disallowed (confusing) +// Option C: Warning (use isPlatform instead) +``` + +> idk let's table the eslint stuff for now + +--- + +## 9. Migration Path - Practical Questions + +**Question 9a:** Should we provide a codemod for migrating existing code? + +```typescript +// Transform: +moduleAPI.createSharedStates({...}) +// Into: +moduleAPI.shared.createSharedStates({...}) + +// Automatically via: +npx springboard migrate-api +``` + +**Question 9b:** Staging the rollout + +Since this is a breaking change, should we: +- Phase 1: Release new API alongside old (both work) +- Phase 2: Deprecate old API (warnings) +- Phase 3: Remove old API + +Or just hard break immediately? + +**Question 9c:** Version numbering + +Should this be: +- Springboard v2.0.0 (major version bump) +- Springboard v1.x.0 with feature flag +- Springboard v1.x.0 with opt-in via config + +> don't worry about breaking changes. we're still in experimental phase + +--- + +## 10. Final Implementation Details + +**Question 10a:** Should `Object.freeze()` be optional? + +Some developers might want to mutate framework objects for testing or debugging. + +```typescript +// Option A: Always freeze (strict) +// Option B: Freeze in production only +// Option C: Config option: { freezeFrameworkObjects: true } +``` + +> freeze by default, and allow it to be disabled on init. we will eventually get to an isolated non-side-effect init process where this will be more secure. let's have a `springboard.configure({disableObjectFreeze?: boolean})` that is inherently insecure for now + +**Question 10b:** Error message quality + +For stripped server code accessed in browser: + +```typescript +// Current plan: +serverStates.apiKey.getState() +// Error: Cannot access server state "apiKey" from client build. + +// Should we also: +// - Show stack trace to where it was defined? +// - Link to docs? +// - Show what alternatives exist? +Error: Cannot access server state "apiKey" from client build. + +Server states are only accessible server-side for security. + +Did you mean to: + • Use shared state: moduleAPI.shared.createSharedStates() + • Call from server action: moduleAPI.server.createServerActions() + +Docs: https://docs.springboard.dev/server-states +``` + +> Looks good to me + +--- + +## Summary + +The main areas needing clarification: + +1. **Platform detection** - ANY vs ALL logic, constants, compile vs runtime +2. **Client actions** - Timeout defaults, callback pattern, server-generated IDs, "resumable" meaning +3. **UserAgent on server** - Document, warn, or prevent? +4. **Babel transformer** - Order, test sharing, relation to existing plugin +5. **State versioning** - Initial version number, migration enforcement, database schema +6. **Record states** - Database design, sync strategy, performance limits +7. **Platform code edge cases** - Compilation strategy for complex expressions +8. **ESLint** - Support both syntaxes, nested blocks? +9. **Migration** - Codemod, staging, versioning? +10. **Polish** - Optional freezing, error message quality + +Let me know which of these you want to discuss, or if you're ready to move forward with implementation! diff --git a/claude_notes/005-IMPLEMENTATION_SPEC.md b/claude_notes/005-IMPLEMENTATION_SPEC.md new file mode 100644 index 00000000..bf6386f2 --- /dev/null +++ b/claude_notes/005-IMPLEMENTATION_SPEC.md @@ -0,0 +1,912 @@ +# Implementation Specification - ModuleAPI v2 + +This document contains all finalized decisions and serves as the single source of truth for implementation. + +--- + +## 🎯 Core API - Final Structure + +```typescript +// ============================================ +// SPRINGBOARD GLOBAL +// ============================================ + +springboard.registerModule(moduleId, options, callback) +springboard.registerClassModule(callback) +springboard.registerSplashScreen(Component) +springboard.runOn(platform, callback) // Returns T | Promise | null +springboard.isPlatform(platform) // Runtime check only +springboard.platform // Current platform string +springboard.configure({disableObjectFreeze?: boolean}) + +// ============================================ +// MODULE API +// ============================================ + +// Server namespace (stripped from client builds) +moduleAPI.server.createServerStates(states) +moduleAPI.server.createServerActions(actions) +moduleAPI.server.createServerRecordStates(recordStates) + +// Shared namespace (synced across all clients) +moduleAPI.shared.createSharedStates(states) +moduleAPI.shared.createSharedActions(actions) +moduleAPI.shared.createSharedRecordStates(recordStates) + +// UserAgent namespace (device-local storage) +moduleAPI.userAgent.createUserAgentStates(states) +moduleAPI.userAgent.createUserAgentActions(actions) +moduleAPI.userAgent.createUserAgentRecordStates(recordStates) + +// Client namespace (server→client RPC) +moduleAPI.client.createClientActions(actions) + +// UI namespace +moduleAPI.ui.registerRoute(path, options, component) +moduleAPI.ui.registerApplicationShell(component) +moduleAPI.ui.registerReactProvider(provider) // Future + +// Top-level utilities +moduleAPI.getModule(moduleId) +moduleAPI.onDestroy(callback) +moduleAPI.moduleId +moduleAPI.fullPrefix + +// Internal (discouraged) +moduleAPI._internal.statesAPI +moduleAPI._internal.deps +``` + +--- + +## 🔨 Build Transformations + +### 1. Server State Removal + +**Detection:** Method name `createServerStates` + +```typescript +// Source +const serverStates = await moduleAPI.server.createServerStates({ + apiKey: process.env.STRIPE_KEY +}); + +// Client build → ENTIRE DECLARATION REMOVED +// (nothing) + +// Server build → unchanged +const serverStates = await moduleAPI.server.createServerStates({ + apiKey: process.env.STRIPE_KEY +}); +``` + +### 2. Server Action Body Stripping + +**Detection:** Method name `createServerActions` + +```typescript +// Source +const serverActions = moduleAPI.server.createServerActions({ + authenticate: async (args) => { + const key = serverStates.apiKey.getState(); + return {authenticated: true}; + } +}); + +// Client build → BODY STRIPPED +const serverActions = moduleAPI.server.createServerActions({ + authenticate: async () => {} +}); + +// Server build → unchanged +``` + +### 3. Platform-Specific Code (`springboard.runOn`) + +**Detection:** `springboard.runOn(platform, callback)` + +**Strategy:** Remove callback body for non-matching platforms, return `null` at runtime + +```typescript +// Source +const deps = springboard.runOn('node', async () => { + const midi = await import('midi-service'); + return {midi}; +}); + +// Node build → Execute callback +const deps = await (async () => { + const midi = await import('midi-service'); + return {midi}; +})(); + +// Browser build → Return null +const deps = null; +``` + +**Complex expressions work naturally:** + +```typescript +// Source +const deps = springboard.runOn('node', async () => { + return {midi: await import('midi')}; +}) ?? springboard.runOn('browser', async () => { + return {audio: await import('audio')}; +}); + +// Node build +const deps = await (async () => { + return {midi: await import('midi')}; +})() ?? null; + +// Browser build +const deps = null ?? await (async () => { + return {audio: await import('audio')}; +})(); +``` + +**Document the platform matrix:** +- `node` + `server` +- `cf-workers` + `server` +- `web` + `browser` + `client` + `user-agent` +- `tauri` + `browser` + `client` + `user-agent` +- `react-native-web` + `browser` + `client` +- `react-native` + `user-agent` + +--- + +## 🌐 Platform Detection + +### Runtime Only (No Compile-Time Optimization) + +```typescript +// Single argument only +springboard.isPlatform('server') // boolean +springboard.isPlatform('node') // boolean + +// Current platform +springboard.platform // string: 'node' | 'browser' | 'cf-workers' | etc. + +// Common patterns +if (springboard.isPlatform('server')) { + // Do server things +} + +// Multiple checks +const isServer = springboard.isPlatform('server'); +const isNode = springboard.isPlatform('node'); +if (isServer && isNode) { + // Only on node server (not CF workers) +} +``` + +**Note:** These are runtime checks only. They do NOT affect compilation. Use `springboard.runOn()` for compile-time code removal. + +--- + +## 🔄 Client Actions - Server→Client RPC + +### Timeout Behavior + +**Cascade:** Call-level → Action-level → Global default (5000ms) + +```typescript +// Global default +const DEFAULT_TIMEOUT = 5000; + +// Action-level timeout +const clientActions = moduleAPI.client.createClientActions({ + toast: { + handler: async (args: {message: string}) => { + showNotification(args.message); + }, + timeout: 10000 // 10 seconds for this action + }, + + // Can also use function directly (uses default timeout) + quickAction: async (args) => { + doSomething(); + } +}); + +// Call-level timeout (overrides action-level) +await clientActions.toast({message: 'hi'}, userContext, {timeout: 3000}); +``` + +### Async Pattern with Optional Callback + +Use `.then()` for fire-and-forget with callback: + +```typescript +// Fire-and-forget with no callback +clientActions.toast({message: 'hi'}, userContext); + +// Fire-and-forget with callback using .then() +clientActions.toast({message: 'hi'}, userContext).then( + result => console.log('Success:', result), + error => console.error('Error:', error) +); + +// Await if you need the result +const result = await clientActions.toast({message: 'hi'}, userContext); +``` + +### Timeout Return Value + +**Never throw on timeout.** Return error object: + +```typescript +const result = await clientActions.toast({message: 'hi'}, userContext); +if (result.error) { + // Handle timeout or other error + console.log(result.error); // 'timed out' or other error message +} else { + // Success + console.log(result.data); +} +``` + +### Server-Generated IDs (Convention) + +Framework doesn't enforce, but document the pattern: + +```typescript +const serverActions = moduleAPI.server.createServerActions({ + doLongOperation: async (args, userContext) => { + // Generate ID on server to avoid waiting + const operationId = uuid(); + + // Send initial notification + clientActions.toast({ + message: 'Starting...', + id: operationId + }, userContext); + + // Do work + await doWork(); + + // Update same notification + clientActions.toast({ + message: 'Complete!', + id: operationId + }, userContext); + } +}); +``` + +### Call Modes + +```typescript +// Specific user (default) +await clientActions.toast({message: 'hi'}, userContext); + +// Local (React Native → WebView) +await clientActions.toast({message: 'hi'}, {mode: 'local'}); + +// Broadcast to all +await clientActions.toast({message: 'System update'}, {mode: 'broadcast'}); + +// Broadcast except current user +await clientActions.toast({message: 'Someone else joined'}, { + mode: 'broadcast_exclude_current_user', + userContext +}); +``` + +--- + +## 🗂️ State Versioning + +### Smooth Transition + +```typescript +// Non-versioned (version implicitly null) +const states = await moduleAPI.shared.createSharedStates({ + myState: {message: 'hello'} +}); + +// Add version later +const states = await moduleAPI.shared.createSharedStates({ + myState: { + value: {message: 'hello'}, + version: 2, + migrate: async (current) => { + // current.version is null for old data + if (current.version === null) { + // Migrate from null → version 2 + return {message: current.state.message || 'default'}; + } + if (current.version === 1) { + // Migrate from v1 → v2 + return migrateV1ToV2(current.state); + } + return current.state; // Already v2 + } + } +}); +``` + +### TypeScript Detection + +```typescript +type StateConfig = + | T // Simple value (no version) + | { // Versioned value + value: T; + version: number; + migrate: (current: {state: any, version: number | null}) => T | Promise; + }; + +// Both valid: +const states = await moduleAPI.shared.createSharedStates({ + simple: {count: 0}, // No version + versioned: { + value: {count: 0}, + version: 1, + migrate: async (current) => { /* required */ } + } +}); +``` + +### Database Schema + +**Use metadata column in existing kvstore:** + +```sql +CREATE TABLE kvstore ( + key TEXT PRIMARY KEY, + value TEXT, -- JSON + metadata TEXT -- JSON: {version: number} +); +``` + +**Enforce `migrate` when `version` is provided** (TypeScript + runtime check) + +--- + +## 📦 Record States + +### New Table Schema + +```sql +CREATE TABLE kvstore_records ( + key TEXT, -- 'prefix|state|users' + record_id TEXT, -- '123' (always string) + value TEXT, -- JSON + metadata TEXT, -- JSON: {version: number} + PRIMARY KEY (key, record_id) +); + +CREATE INDEX idx_kvstore_records_key ON kvstore_records(key); +``` + +### API + +```typescript +const users = await moduleAPI.shared.createSharedRecordStates({ + users: { + initialRecords: [ + {id: '1', name: 'Alice'}, + {id: '2', name: 'Bob'} + ], + version: 1, // Optional + migrate: async (current) => { /* Required if version provided */ } + } +}); + +// Methods +users.add({id: '3', name: 'Charlie'}) +users.update('1', {name: 'Alice Updated'}) +users.upsert({id: '1', name: 'Alice'}) // Create or update +users.replace('1', {id: '1', name: 'Alice', role: 'admin'}) +users.remove('2') +users.getById('1') +users.getAll() +users.useState() // React hook - all records +users.useById('1') // React hook - single record +``` + +### Sync Strategy + +**Individual record sync** (not full collection): + +```typescript +// Client receives: +{ + type: 'recordUpdate', + key: 'prefix|state|users', + recordId: '123', + value: {id: '123', name: 'Updated'} +} + +// Not: +{ + type: 'recordState', + key: 'prefix|state|users', + value: [allUsers] // ❌ Too much data +} +``` + +### Performance + +**Default:** Load all records into memory (consistent with current state pattern) + +**Future:** Add `avoidPreload` option when needed: + +```typescript +const users = await moduleAPI.shared.createSharedRecordStates({ + users: { + initialRecords: [], + avoidPreload: true // Don't load all records on init + } +}); + +// Then must explicitly load +await users.loadById('123'); // Lazy load single record +await users.loadAll(); // Load everything +``` + +--- + +## ⚠️ UserAgent State on Server + +### Warning Strategy + +**Warn when accessed, not created:** + +```typescript +// Server code - creating is OK (no warning) +const userAgentStates = await moduleAPI.userAgent.createUserAgentStates({ + theme: 'dark' +}); + +// Server code - accessing triggers warning +const theme = userAgentStates.theme.getState(); +// Console warning: +// "UserAgent state 'theme' accessed on server. This is rarely needed. +// Consider using server state, or check isPlatform('client') before accessing." +``` + +### Suggested Pattern in Warning + +```typescript +if (springboard.isPlatform('client')) { + const theme = userAgentStates.theme.getState(); // No warning +} +``` + +--- + +## 🔧 Object Freezing + +### Configuration + +```typescript +// Default: Freeze everything +springboard.configure({ + disableObjectFreeze: false // Default +}); + +// Opt-out for testing/debugging +springboard.configure({ + disableObjectFreeze: true // Inherently insecure +}); +``` + +### What Gets Frozen + +```typescript +// 1. ModuleAPI and all namespaces +Object.freeze(moduleAPI); +Object.freeze(moduleAPI.server); +Object.freeze(moduleAPI.shared); +Object.freeze(moduleAPI.userAgent); +Object.freeze(moduleAPI.client); +Object.freeze(moduleAPI.ui); + +// 2. State supervisors +const states = await moduleAPI.shared.createSharedStates({...}); +Object.freeze(states); +Object.freeze(states.myState); + +// 3. Actions +const actions = moduleAPI.shared.createSharedActions({...}); +Object.freeze(actions); + +// 4. Modules after initialization +Object.freeze(module); +``` + +--- + +## 🚨 Error Messages + +### Server State Access from Client + +```typescript +// Client tries to access server state +serverStates.apiKey.getState(); + +// Error thrown: +Error: Cannot access server state "apiKey" from client build. + +Server states are only accessible server-side for security. + +Did you mean to: + • Use shared state: moduleAPI.shared.createSharedStates() + • Call from server action: moduleAPI.server.createServerActions() + +Docs: https://docs.springboard.dev/server-states +``` + +### Client Action Timeout + +```typescript +const result = await clientActions.toast({message: 'hi'}, userContext); + +// On timeout, result is: +{ + error: 'timed out', + timeout: 5000, + actionName: 'toast' +} +``` + +--- + +## 🧪 Testing Strategy + +### Shared Test Suites + +Create shared test cases for esbuild and babel transformers: + +```typescript +// packages/springboard/cli/test/transformer-shared-tests.ts +export const transformerTests = [ + { + name: 'removes server state declarations', + input: ` + const serverStates = await moduleAPI.server.createServerStates({ + secret: 'key' + }); + `, + clientOutput: '', // Removed entirely + serverOutput: '...' // Unchanged + }, + { + name: 'strips server action bodies', + input: ` + const actions = moduleAPI.server.createServerActions({ + doThing: async (args) => { + return {result: 'data'}; + } + }); + `, + clientOutput: ` + const actions = moduleAPI.server.createServerActions({ + doThing: async () => {} + }); + `, + serverOutput: '...' // Unchanged + }, + { + name: 'handles runOn for node', + input: ` + const deps = springboard.runOn('node', () => { + return require('midi'); + }); + `, + nodeOutput: ` + const deps = (() => { + return require('midi'); + })(); + `, + browserOutput: 'const deps = null;' + }, + // ... more cases +]; +``` + +### ESBuild Plugin Tests + +```typescript +// packages/springboard/cli/test/esbuild-plugin.test.ts +import {esbuildPluginPlatformInject} from '../src/esbuild_plugins/esbuild_plugin_platform_inject'; +import {transformerTests} from './transformer-shared-tests'; + +describe('esbuild plugin', () => { + transformerTests.forEach(test => { + it(test.name, async () => { + const clientResult = await transformWithPlugin(test.input, 'browser'); + expect(normalize(clientResult)).toBe(normalize(test.clientOutput)); + + const serverResult = await transformWithPlugin(test.input, 'node'); + expect(normalize(serverResult)).toBe(normalize(test.serverOutput)); + }); + }); +}); +``` + +### Babel Plugin Tests + +```typescript +// packages/springboard/cli/test/babel-plugin.test.ts +import {babelPluginPlatformInject} from '../src/babel_plugins/babel_plugin_platform_inject'; +import {transformerTests} from './transformer-shared-tests'; + +describe('babel plugin', () => { + transformerTests.forEach(test => { + it(test.name, () => { + const clientResult = babelTransform(test.input, 'browser'); + expect(normalize(clientResult)).toBe(normalize(test.clientOutput)); + + const serverResult = babelTransform(test.input, 'node'); + expect(normalize(serverResult)).toBe(normalize(test.serverOutput)); + }); + }); +}); +``` + +--- + +## 📚 Documentation Requirements + +### 1. API Reference (TypeDoc) + +Auto-generate from comprehensive JSDoc comments: + +```typescript +/** + * Create server-only states that are never synced to clients. + * + * **Security:** State values are only accessible server-side. In client builds, + * the entire variable declaration is removed by the compiler. + * + * **Storage:** Persisted to server storage (database/filesystem). + * + * **Sync:** Never synced to clients. Use `shared.createSharedStates()` for synced state. + * + * @example + * ```typescript + * const serverStates = await moduleAPI.server.createServerStates({ + * apiKey: process.env.STRIPE_KEY, + * dbPassword: process.env.DB_PASSWORD + * }); + * + * // In server action + * const key = serverStates.apiKey.getState(); + * ``` + * + * @see {@link https://docs.springboard.dev/server-states | Server States Guide} + */ +createServerStates>( + states: States +): Promise<{[K in keyof States]: ServerStateSupervisor}> +``` + +### 2. Platform Matrix Document + +Create table showing what platforms map to what tags: + +| Runtime Environment | Platform Tags | +|---------------------|---------------| +| Node.js server | `node`, `server` | +| Cloudflare Workers | `cf-workers`, `server` | +| Web browser | `web`, `browser`, `client`, `user-agent` | +| Tauri desktop | `tauri`, `browser`, `client`, `user-agent` | +| React Native Web | `react-native-web`, `browser`, `client` | +| React Native | `react-native`, `user-agent` | + +### 3. Migration Examples + +Document common patterns with before/after: +- Simple shared state +- Server-only logic +- Client actions integration +- Platform-specific code +- Record states + +--- + +## 🎯 Implementation Phases + +### Phase 1: Core Refactor (Week 1-2) +- [ ] Create namespace classes (ServerAPI, SharedAPI, UserAgentAPI, ClientAPI, UIAPI) +- [ ] Implement all `create*` methods with verbose names +- [ ] Move existing APIs to `_internal.*` +- [ ] Add `springboard.configure()` for object freezing +- [ ] Implement object freezing logic (deep freeze) +- [ ] Add comprehensive JSDoc to all public methods +- [ ] Update existing modules to use new API + +### Phase 2: Build System (Week 2-3) +- [ ] Update esbuild plugin to detect new method names +- [ ] Implement `springboard.runOn()` transformation +- [ ] Strip server state variable declarations +- [ ] Strip server action bodies (keep structure) +- [ ] Create shared test suite +- [ ] Add esbuild plugin tests (100+ test cases) +- [ ] Test transformation edge cases + +### Phase 3: Platform Detection (Week 3) +- [ ] Implement `springboard.isPlatform(platform)` +- [ ] Implement `springboard.platform` getter +- [ ] Add runtime platform detection logic +- [ ] Document platform matrix +- [ ] Add unit tests + +### Phase 4: Client Actions (Week 3-4) +- [ ] Implement `createClientActions()` method +- [ ] Add timeout cascade logic (call → action → global) +- [ ] Support both function and config object patterns +- [ ] Implement userContext parameter in RPC framework +- [ ] Add call modes: `local`, `broadcast`, `broadcast_exclude_current_user` +- [ ] Return error objects on timeout (no throw) +- [ ] Add client action registry +- [ ] Implement server→client invocation +- [ ] Test React Native → WebView communication + +### Phase 5: State Versioning (Week 4-5) +- [ ] Add metadata column to kvstore table +- [ ] Implement version detection (check for `version` key in config) +- [ ] TypeScript: Require `migrate` when `version` provided +- [ ] Runtime: Validate `migrate` function exists +- [ ] Handle null version (for unversioned data) +- [ ] Store version in metadata column +- [ ] Call migrate function on state initialization +- [ ] Add unit tests for migration logic + +### Phase 6: Record States (Week 5-6) +- [ ] Create `kvstore_records` table with schema +- [ ] Implement `createRecordStates()` for all namespaces +- [ ] Add methods: `add`, `update`, `upsert`, `replace`, `remove` +- [ ] Add getters: `getById`, `getAll` +- [ ] Implement individual record sync (not full collection) +- [ ] Add React hooks: `useState()`, `useById()` +- [ ] Support versioning for record states +- [ ] Test with 1000+ records +- [ ] Document `avoidPreload` for future performance optimization + +### Phase 7: Warnings & Error Messages (Week 6) +- [ ] Implement UserAgent state access warning on server +- [ ] Add helpful suggestions in warning message +- [ ] Implement error stubs for server state access from client +- [ ] Add detailed error messages with docs links +- [ ] Test all error scenarios + +### Phase 8: Babel Plugin (Week 7) +- [ ] Extend existing babel plugin +- [ ] Share logic with esbuild plugin where possible +- [ ] Run shared test suite against babel plugin +- [ ] Ensure output matches esbuild plugin +- [ ] Test with React Native babel config + +### Phase 9: Documentation (Week 8) +- [ ] Write condensed API overview (10-20 pages) +- [ ] Generate TypeDoc reference +- [ ] Document platform matrix +- [ ] Update quickstart guide +- [ ] Write security best practices guide +- [ ] Add example integrations (Mantine, etc.) +- [ ] Create migration examples + +### Phase 10: Polish & Launch (Week 9) +- [ ] Add VS Code snippets +- [ ] Improve error messages based on testing +- [ ] Performance testing +- [ ] Security audit +- [ ] Community preview (2-3 developers) +- [ ] Address feedback +- [ ] Public release + +--- + +## ✅ Definition of Done + +Each feature is complete when: +- [ ] Implementation matches this spec +- [ ] Unit tests written and passing (>90% coverage) +- [ ] Integration tests passing +- [ ] JSDoc comments added +- [ ] TypeScript types are correct +- [ ] No TypeScript errors in strict mode +- [ ] Manual testing completed +- [ ] Documentation written +- [ ] Code review completed +- [ ] Merged to main branch + +--- + +## 🚨 Out of Scope (For Now) + +Explicitly NOT implementing in this phase: +- ❌ ESLint rules (tabled for future) +- ❌ Migration codemods (experimental phase, breaking changes OK) +- ❌ Backwards compatibility (hard break) +- ❌ Migration guides (not needed yet) +- ❌ Record state pagination/windowing (address when needed) +- ❌ Migration rollback support (add if users request) +- ❌ Composite keys for records (string `id` only) +- ❌ Complex platform nesting in ESLint (tabled) + +--- + +## 📝 Notes for Implementation + +### Code Organization + +``` +packages/springboard/ +├── core/ +│ ├── engine/ +│ │ ├── module_api.ts # Main ModuleAPI class +│ │ ├── server_api.ts # ServerAPI namespace +│ │ ├── shared_api.ts # SharedAPI namespace +│ │ ├── user_agent_api.ts # UserAgentAPI namespace +│ │ ├── client_api.ts # ClientAPI namespace (new) +│ │ ├── ui_api.ts # UIAPI namespace +│ │ └── register.ts # springboard global +│ ├── services/ +│ │ ├── states/ +│ │ │ ├── server_state_supervisor.ts +│ │ │ ├── shared_state_supervisor.ts +│ │ │ ├── user_agent_state_supervisor.ts +│ │ │ └── record_state_supervisor.ts # New +│ │ └── platform/ +│ │ ├── platform_detector.ts # New +│ │ └── platform_types.ts # New +├── cli/ +│ ├── src/ +│ │ ├── esbuild_plugins/ +│ │ │ └── esbuild_plugin_platform_inject.ts +│ │ └── babel_plugins/ +│ │ └── babel_plugin_platform_inject.ts +│ └── test/ +│ ├── transformer-shared-tests.ts # Shared test cases +│ ├── esbuild-plugin.test.ts +│ └── babel-plugin.test.ts +└── data_storage/ + ├── kv_api.ts # Updated for metadata + └── record_store_api.ts # New +``` + +### Key Implementation Details + +1. **Keep existing code working during development** - Use feature flags or parallel implementations + +2. **Write tests first** for build transformations - These are critical and hard to debug + +3. **Freeze gradually** - Start with ModuleAPI, expand to states, get feedback before freezing everything + +4. **Platform detection is simple** - Just check `process` object, `window` object, or other globals + +5. **Client actions need userContext** - Add to RPC framework, include connectId, userId, sessionId + +6. **Record states are complex** - Start simple (all in memory), optimize later + +7. **Version migration runs once** - On state initialization, check stored version vs declared version + +8. **Babel and esbuild must match** - Share test suite, compare outputs + +9. **Error messages are UX** - Spend time making them helpful, not just informative + +10. **Document the "why"** - Not just "how to use", but "when to use" and "why it works this way" + +--- + +## 🎉 Success Criteria + +This implementation is successful when: + +1. **All tests passing** - Unit, integration, and transformation tests +2. **TypeScript errors = 0** - In strict mode +3. **Existing modules migrated** - All internal modules use new API +4. **Build outputs correct** - Server code stripped from client builds +5. **Documentation complete** - API reference, guides, examples +6. **Performance maintained** - No significant regression in build time or runtime +7. **Security validated** - Server states never leak to client +8. **Community feedback positive** - 2-3 developers test and approve + +--- + +This spec is the authoritative source for implementation. Any changes should update this document first, then implement. diff --git a/packages/jamtools/core/modules/chord_families/chord_families_module.tsx b/packages/jamtools/core/modules/chord_families/chord_families_module.tsx index 1d532379..444cf731 100644 --- a/packages/jamtools/core/modules/chord_families/chord_families_module.tsx +++ b/packages/jamtools/core/modules/chord_families/chord_families_module.tsx @@ -106,10 +106,13 @@ declare module 'springboard/module_registry/module_registry' { // }); springboard.registerModule('chord_families', {}, async (moduleAPI) => { - const savedData = await moduleAPI.statesAPI.createSharedState('all_chord_families', []); + const states = await moduleAPI.shared.createSharedStates({ + all_chord_families: [] as ChordFamilyData[], + state: {chord: null, scale: 0} as State, + }); const getChordFamilyHandler = (key: string): ChordFamilyHandler => { - const data = savedData.getState()[0]; + const data = states.all_chord_families.getState()[0]; return new ChordFamilyHandler(data); }; @@ -121,18 +124,16 @@ springboard.registerModule('chord_families', {}, async (moduleAPI) => { // C major on page load let scale = 0; - const rootModeState = await moduleAPI.statesAPI.createSharedState('state', {chord: null, scale}); - const setScale = (newScale: number) => { scale = newScale; - rootModeState.setState({ + states.state.setState({ chord: null, scale, }); }; moduleAPI.registerRoute('', {}, () => { - const state = rootModeState.useState(); + const state = states.state.useState(); const onClick = () => { setScale(cycle(state.scale + 1)); @@ -171,13 +172,13 @@ springboard.registerModule('chord_families', {}, async (moduleAPI) => { } if (evt.event.type === 'noteon') { - rootModeState.setState({ + states.state.setState({ chord: scaleDegreeInfo, scale, }); } else if (evt.event.type === 'noteoff') { // this naive logic is currently causing the second chord to disappear if the first one is released after pressing the second one - rootModeState.setState({ + states.state.setState({ chord: null, scale, }); diff --git a/packages/jamtools/core/modules/io/io_module.tsx b/packages/jamtools/core/modules/io/io_module.tsx index 4474310b..770c7d3d 100644 --- a/packages/jamtools/core/modules/io/io_module.tsx +++ b/packages/jamtools/core/modules/io/io_module.tsx @@ -134,7 +134,10 @@ export class IoModule implements Module { midiOutputDevices: [], }; - this.midiDeviceState = await moduleAPI.statesAPI.createSharedState('plugged_in_midi_devices', state); + const sharedStates = await moduleAPI.shared.createSharedStates({ + plugged_in_midi_devices: state + }); + this.midiDeviceState = sharedStates.plugged_in_midi_devices; }; public sendMidiEvent = (outputName: string, midiEvent: MidiEvent) => { diff --git a/packages/jamtools/core/modules/macro_module/macro_module.tsx b/packages/jamtools/core/modules/macro_module/macro_module.tsx index ece2ecf2..d6bac6d7 100644 --- a/packages/jamtools/core/modules/macro_module/macro_module.tsx +++ b/packages/jamtools/core/modules/macro_module/macro_module.tsx @@ -148,9 +148,14 @@ export class MacroModule implements Module { return (args: any) => action(args, this.localMode ? {mode: 'local'} : undefined); }, statesAPI: { - createSharedState: (key: string, defaultValue: State) => { - const func = this.localMode ? moduleAPI.statesAPI.createUserAgentState : moduleAPI.statesAPI.createSharedState; - return func(key, defaultValue); + createSharedState: async (key: string, defaultValue: State) => { + if (this.localMode) { + const states = await moduleAPI.userAgent.createUserAgentStates({[key]: defaultValue}); + return states[key]; + } else { + const states = await moduleAPI.shared.createSharedStates({[key]: defaultValue}); + return states[key]; + } }, }, createMacro: this.createMacro, diff --git a/packages/jamtools/features/modules/dashboards/keytar_and_foot_dashboard/module_or_snack_template.tsx b/packages/jamtools/features/modules/dashboards/keytar_and_foot_dashboard/module_or_snack_template.tsx index 42052f83..0bf947d7 100644 --- a/packages/jamtools/features/modules/dashboards/keytar_and_foot_dashboard/module_or_snack_template.tsx +++ b/packages/jamtools/features/modules/dashboards/keytar_and_foot_dashboard/module_or_snack_template.tsx @@ -17,9 +17,12 @@ async function promiseAllObject>>( } const createStates = async (moduleAPI: ModuleAPI) => { - return promiseAllObject({ - myState: moduleAPI.statesAPI.createSharedState('myState', 'initial state'), + const states = await moduleAPI.shared.createSharedStates({ + myState: 'initial state', }); + return { + myState: states.myState, + }; }; const createMacros = async (moduleAPI: ModuleAPI) => { diff --git a/packages/jamtools/features/modules/dashboards/keytar_and_foot_dashboard/multi_octave_supervisor.tsx b/packages/jamtools/features/modules/dashboards/keytar_and_foot_dashboard/multi_octave_supervisor.tsx index 43ed3bb2..0e79fe66 100644 --- a/packages/jamtools/features/modules/dashboards/keytar_and_foot_dashboard/multi_octave_supervisor.tsx +++ b/packages/jamtools/features/modules/dashboards/keytar_and_foot_dashboard/multi_octave_supervisor.tsx @@ -151,9 +151,9 @@ export class MultiOctaveSupervisor { debugSavedInputEvent, debugMidiState, ] = await Promise.all([ - this.moduleAPI.statesAPI.createSharedState(makeStateName('enableDebugging'), true), - this.moduleAPI.statesAPI.createSharedState(makeStateName('debugSavedInputEvent'), null), - this.moduleAPI.statesAPI.createSharedState(makeStateName('debugMidiState'), this.midiState), + this.moduleAPI.shared.createSharedStates({[makeStateName('enableDebugging')]: true}).then(s => s[makeStateName('enableDebugging')]), + this.moduleAPI.shared.createSharedStates({[makeStateName('debugSavedInputEvent')]: null as MidiEventFull | null}).then(s => s[makeStateName('debugSavedInputEvent')]), + this.moduleAPI.shared.createSharedStates({[makeStateName('debugMidiState')]: this.midiState as MultiOctaveSupervisorMidiState}).then(s => s[makeStateName('debugMidiState')]), ]); return { diff --git a/packages/jamtools/features/modules/dashboards/keytar_and_foot_dashboard/single_octave_root_mode_supervisor.tsx b/packages/jamtools/features/modules/dashboards/keytar_and_foot_dashboard/single_octave_root_mode_supervisor.tsx index d309b09a..0e90d201 100644 --- a/packages/jamtools/features/modules/dashboards/keytar_and_foot_dashboard/single_octave_root_mode_supervisor.tsx +++ b/packages/jamtools/features/modules/dashboards/keytar_and_foot_dashboard/single_octave_root_mode_supervisor.tsx @@ -371,9 +371,9 @@ export class SingleOctaveRootModeSupervisor { debugSavedInputEvent, debugMidiState, ] = await Promise.all([ - this.moduleAPI.statesAPI.createSharedState(makeStateName('enableDebugging'), true), - this.moduleAPI.statesAPI.createSharedState(makeStateName('debugSavedInputEvent'), null), - this.moduleAPI.statesAPI.createSharedState(makeStateName('debugMidiState'), this.midiState), + this.moduleAPI.shared.createSharedStates({[makeStateName('enableDebugging')]: true}).then(s => s[makeStateName('enableDebugging')]), + this.moduleAPI.shared.createSharedStates({[makeStateName('debugSavedInputEvent')]: null as MidiEventFull | null}).then(s => s[makeStateName('debugSavedInputEvent')]), + this.moduleAPI.shared.createSharedStates({[makeStateName('debugMidiState')]: this.midiState as SingleOctaveRootModeSupervisorMidiState}).then(s => s[makeStateName('debugMidiState')]), ]); return { diff --git a/packages/jamtools/features/modules/daw_interaction_module.tsx b/packages/jamtools/features/modules/daw_interaction_module.tsx index 8aab897e..4bf0b9cf 100644 --- a/packages/jamtools/features/modules/daw_interaction_module.tsx +++ b/packages/jamtools/features/modules/daw_interaction_module.tsx @@ -5,8 +5,12 @@ import springboard from 'springboard'; // import {GuitarComponent} from './song_structures/components/guitar'; springboard.registerModule('daw_interaction', {}, async (moduleAPI) => { - const sliderPositionState1 = await moduleAPI.statesAPI.createSharedState('slider_position_1', 0); - const sliderPositionState2 = await moduleAPI.statesAPI.createSharedState('slider_position_2', 0); + const states = await moduleAPI.shared.createSharedStates({ + slider_position_1: 0, + slider_position_2: 0, + }); + const sliderPositionState1 = states.slider_position_1; + const sliderPositionState2 = states.slider_position_2; const ccOutput1 = await moduleAPI.deps.module.moduleRegistry.getModule('macro').createMacro(moduleAPI, 'cc_output_1', 'midi_control_change_output', {}); const ccOutput2 = await moduleAPI.deps.module.moduleRegistry.getModule('macro').createMacro(moduleAPI, 'cc_output_2', 'midi_control_change_output', {}); diff --git a/packages/jamtools/features/modules/eventide/eventide_module.tsx b/packages/jamtools/features/modules/eventide/eventide_module.tsx index ed7aa518..9b44da00 100644 --- a/packages/jamtools/features/modules/eventide/eventide_module.tsx +++ b/packages/jamtools/features/modules/eventide/eventide_module.tsx @@ -14,8 +14,12 @@ type EventidePresetState = { } springbord.registerModule('Eventide', {}, async (moduleAPI) => { - const currentPresetState = await moduleAPI.statesAPI.createSharedState('currentPresetState', null); - const favoritedPresetsState = await moduleAPI.statesAPI.createSharedState('favoritedPresets', []); + const states = await moduleAPI.shared.createSharedStates({ + currentPresetState: null as EventidePresetState | null, + favoritedPresets: [] as string[], + }); + const currentPresetState = states.currentPresetState; + const favoritedPresetsState = states.favoritedPresets; const macroModule = moduleAPI.deps.module.moduleRegistry.getModule('macro'); const eventideMacro = await macroModule.createMacro(moduleAPI, 'eventide_pedal', 'musical_keyboard_output', {}); diff --git a/packages/jamtools/features/modules/midi_playback/midi_playback_module.tsx b/packages/jamtools/features/modules/midi_playback/midi_playback_module.tsx index 6a625e96..578b5ff0 100644 --- a/packages/jamtools/features/modules/midi_playback/midi_playback_module.tsx +++ b/packages/jamtools/features/modules/midi_playback/midi_playback_module.tsx @@ -19,7 +19,10 @@ type MidiPlaybackModuleReturnValue = { springboard.registerModule('MidiPlayback', {}, async (moduleAPI): Promise => { const midiFileModule = moduleAPI.deps.module.moduleRegistry.getModule('MidiFile'); - const savedMidiFileData = await moduleAPI.statesAPI.createSharedState('savedMidiFileData', null); + const states = await moduleAPI.shared.createSharedStates({ + savedMidiFileData: null as ParsedMidiFile | null, + }); + const savedMidiFileData = states.savedMidiFileData; const outputDevice = await moduleAPI.deps.module.moduleRegistry.getModule('macro').createMacro(moduleAPI, 'outputDevice', 'musical_keyboard_output', {}); diff --git a/packages/jamtools/features/modules/song_structures_dashboards/song_structures_dashboards_module.tsx b/packages/jamtools/features/modules/song_structures_dashboards/song_structures_dashboards_module.tsx index 5b076de1..fa8044af 100644 --- a/packages/jamtools/features/modules/song_structures_dashboards/song_structures_dashboards_module.tsx +++ b/packages/jamtools/features/modules/song_structures_dashboards/song_structures_dashboards_module.tsx @@ -29,13 +29,19 @@ const initialGuitarDisplaySettings: GuitarDisplaySettings = { }; springboard.registerModule('song_structures_dashboards', {}, async (moduleAPI): Promise => { - const states = moduleAPI.statesAPI; const macros = moduleAPI.deps.module.moduleRegistry.getModule('macro'); - const state = await states.createUserAgentState('guitar_display_settings', initialGuitarDisplaySettings); + const userAgentStates = await moduleAPI.userAgent.createUserAgentStates({ + guitar_display_settings: initialGuitarDisplaySettings, + }); + const state = userAgentStates.guitar_display_settings; - const draftChordsState = await states.createSharedState('draft_chord_choices', null); - const confirmedChordsState = await states.createSharedState('confirmed_chord_choices', null); + const sharedStates = await moduleAPI.shared.createSharedStates({ + draft_chord_choices: null as ChordChoice[] | null, + confirmed_chord_choices: null as ChordChoice[] | null, + }); + const draftChordsState = sharedStates.draft_chord_choices; + const confirmedChordsState = sharedStates.confirmed_chord_choices; // const draftScaleChoice = moduleAPI.statesAPI.createSharedState('', true); // const confirmedScaleChoise = moduleAPI.statesAPI.createSharedState('', true); diff --git a/packages/jamtools/features/modules/ultimate_guitar/ultimate_guitar_module.tsx b/packages/jamtools/features/modules/ultimate_guitar/ultimate_guitar_module.tsx index 932aead8..ed014a4e 100644 --- a/packages/jamtools/features/modules/ultimate_guitar/ultimate_guitar_module.tsx +++ b/packages/jamtools/features/modules/ultimate_guitar/ultimate_guitar_module.tsx @@ -82,19 +82,15 @@ class States { constructor(private moduleAPI: ModuleAPI) {} public initialize = async () => { - const [ - savedSetlists, - savedTabs, - currentSetlistStatus, - ] = await Promise.all([ - this.moduleAPI.statesAPI.createSharedState('saved_setlists', []), - this.moduleAPI.statesAPI.createSharedState('saved_tabs', []), - this.moduleAPI.statesAPI.createSharedState('current_setlist_status', null), - ]); + const states = await this.moduleAPI.shared.createSharedStates({ + saved_setlists: [] as UltimateGuitarSetlist[], + saved_tabs: [] as UltimateGuitarTab[], + current_setlist_status: null as UltimateGuitarSetlistStatus | null, + }); - this.savedSetlists = savedSetlists; - this.savedTabs = savedTabs; - this.currentSetlistStatus = currentSetlistStatus; + this.savedSetlists = states.saved_setlists; + this.savedTabs = states.saved_tabs; + this.currentSetlistStatus = states.current_setlist_status; }; } diff --git a/packages/springboard/cli/src/esbuild_plugins/esbuild_plugin_platform_inject.test.ts b/packages/springboard/cli/src/esbuild_plugins/esbuild_plugin_platform_inject.test.ts index 8be1e848..a29a3043 100644 --- a/packages/springboard/cli/src/esbuild_plugins/esbuild_plugin_platform_inject.test.ts +++ b/packages/springboard/cli/src/esbuild_plugins/esbuild_plugin_platform_inject.test.ts @@ -124,3 +124,96 @@ describe('esbuild_plugin_platform_inject', () => { expect(browserBuildContent).not.toContain('Authenticating user:'); }, 60000); }); + +describe('esbuild_plugin_platform_inject - runOn transformation', () => { + const rootDir = path.resolve(__dirname, '../../../../..'); + const cliPath = path.resolve(__dirname, '../cli.ts'); + const testAppPath = 'run_on_test/run_on_test.tsx'; + const distPath = path.resolve(rootDir, 'apps/small_apps/dist'); + + beforeAll(() => { + // Clean dist directory before running tests + if (fs.existsSync(distPath)) { + fs.rmSync(distPath, { recursive: true, force: true }); + } + + // Build the runOn test app using the CLI + execSync(`npx tsx ${cliPath} build ${testAppPath}`, { + cwd: path.resolve(rootDir, 'apps/small_apps'), + stdio: 'inherit', + env: { + ...process.env, + NODE_ENV: 'production', + }, + }); + }); + + it('should replace runOn with null for non-matching platforms in browser build', async () => { + // Read the browser build output + const browserDistPath = path.join(distPath, 'browser/dist'); + const jsFiles = fs.readdirSync(browserDistPath).filter(f => f.endsWith('.js') && f.startsWith('index-')); + expect(jsFiles.length).toBeGreaterThan(0); + + const browserBuildContent = fs.readFileSync(path.join(browserDistPath, jsFiles[0]), 'utf-8'); + + // Browser build should not contain node-specific strings (they should be stripped) + expect(browserBuildContent).not.toContain('node-only-secret'); + expect(browserBuildContent).not.toContain('node-async-data'); + + // Browser build should contain browser-specific strings (callback executed) + expect(browserBuildContent).toContain('browser-only-feature'); + }, 60000); + + it('should replace runOn with IIFE for matching platforms in node build', async () => { + // Read the node build output + const nodeDistPath = path.join(distPath, 'node/dist'); + const jsFiles = fs.readdirSync(nodeDistPath).filter(f => f.endsWith('.js') && f === 'index.js'); + expect(jsFiles.length).toBeGreaterThan(0); + + const nodeBuildContent = fs.readFileSync(path.join(nodeDistPath, jsFiles[0]), 'utf-8'); + + // Node build should contain node-specific strings (callback executed) + expect(nodeBuildContent).toContain('node-only-secret'); + + // Node build should NOT contain browser-specific strings (they should be null) + expect(nodeBuildContent).not.toContain('browser-only-feature'); + }, 60000); + + it('should handle async runOn callbacks correctly', async () => { + // Read the node build output + const nodeDistPath = path.join(distPath, 'node/dist'); + const jsFiles = fs.readdirSync(nodeDistPath).filter(f => f.endsWith('.js') && f === 'index.js'); + const nodeBuildContent = fs.readFileSync(path.join(nodeDistPath, jsFiles[0]), 'utf-8'); + + // The async callback should be preserved in node build + expect(nodeBuildContent).toContain('node-async-data'); + + // Read the browser build output + const browserDistPath = path.join(distPath, 'browser/dist'); + const browserJsFiles = fs.readdirSync(browserDistPath).filter(f => f.endsWith('.js') && f.startsWith('index-')); + const browserBuildContent = fs.readFileSync(path.join(browserDistPath, browserJsFiles[0]), 'utf-8'); + + // The async callback should not appear in browser build + expect(browserBuildContent).not.toContain('node-async-data'); + }, 60000); + + it('should handle chained runOn with ?? operator correctly', async () => { + // Read the node build output + const nodeDistPath = path.join(distPath, 'node/dist'); + const jsFiles = fs.readdirSync(nodeDistPath).filter(f => f.endsWith('.js') && f === 'index.js'); + const nodeBuildContent = fs.readFileSync(path.join(nodeDistPath, jsFiles[0]), 'utf-8'); + + // In node build, the first runOn should execute and second should be null + expect(nodeBuildContent).toContain('node-midi-service'); + expect(nodeBuildContent).not.toContain('browser-audio-service'); + + // Read the browser build output + const browserDistPath = path.join(distPath, 'browser/dist'); + const browserJsFiles = fs.readdirSync(browserDistPath).filter(f => f.endsWith('.js') && f.startsWith('index-')); + const browserBuildContent = fs.readFileSync(path.join(browserDistPath, browserJsFiles[0]), 'utf-8'); + + // In browser build, the first runOn should be null and second should execute + expect(browserBuildContent).not.toContain('node-midi-service'); + expect(browserBuildContent).toContain('browser-audio-service'); + }, 60000); +}); diff --git a/packages/springboard/cli/src/esbuild_plugins/esbuild_plugin_platform_inject.ts b/packages/springboard/cli/src/esbuild_plugins/esbuild_plugin_platform_inject.ts index 1b06421b..d9369691 100644 --- a/packages/springboard/cli/src/esbuild_plugins/esbuild_plugin_platform_inject.ts +++ b/packages/springboard/cli/src/esbuild_plugins/esbuild_plugin_platform_inject.ts @@ -20,10 +20,13 @@ export const esbuildPluginPlatformInject = ( // Early return if file doesn't need any transformations const hasPlatformAnnotations = /@platform "(node|browser|react-native|fetch)"/.test(source); - const hasServerCalls = /createServer(State|States|Action|Actions)/.test(source); + // Detect both old and new API patterns for server calls + const hasServerCalls = /createServer(State|States|Action|Actions)/.test(source) || + /\.server\.createServer(States|Actions)/.test(source); const needsServerProcessing = hasServerCalls && ((platform === 'browser' || platform === 'react-native') && !preserveServerStatesAndActions); + const hasRunOnCalls = /springboard\.runOn\(/.test(source); - if (!hasPlatformAnnotations && !needsServerProcessing) { + if (!hasPlatformAnnotations && !needsServerProcessing && !hasRunOnCalls) { return { contents: source, loader: args.path.split('.').pop() as 'js', @@ -40,8 +43,70 @@ export const esbuildPluginPlatformInject = ( // Remove the code for the other platforms source = source.replace(otherPlatformRegex, ''); + // Transform springboard.runOn() calls + if (hasRunOnCalls) { + try { + const ast = parser.parse(source, { + sourceType: 'module', + plugins: ['typescript', 'jsx'], + }); + + traverse(ast, { + CallExpression(path) { + // Check if this is a springboard.runOn() call + if ( + path.node.callee.type === 'MemberExpression' && + path.node.callee.object.type === 'Identifier' && + path.node.callee.object.name === 'springboard' && + path.node.callee.property.type === 'Identifier' && + path.node.callee.property.name === 'runOn' + ) { + // First argument should be the platform string + const platformArg = path.node.arguments[0]; + const callbackArg = path.node.arguments[1]; + + if ( + platformArg && + platformArg.type === 'StringLiteral' && + callbackArg + ) { + const targetPlatform = platformArg.value; + + // Check if the target platform matches the current build platform + const platformMatches = targetPlatform === platform; + + if (platformMatches) { + // Replace with IIFE: (callback)() + // The await is handled naturally at the parent level if needed + path.replaceWith({ + type: 'CallExpression', + callee: callbackArg as any, + arguments: [], + } as any); + } else { + // Replace with null + path.replaceWith({ + type: 'NullLiteral', + } as any); + } + } + } + }, + }); + + // Generate the modified source + const output = generate(ast, {}, source); + source = output.code; + } catch (err) { + // If AST parsing fails, log warning but continue with original source + console.warn(`Failed to parse ${args.path} for runOn transformation:`, err); + } + } + if ((platform === 'browser' || platform === 'react-native') && !preserveServerStatesAndActions) { - const hasServerCalls = /createServer(State|States|Action|Actions)/.test(source); + // Detect both old and new API patterns for server calls + const hasServerCalls = /createServer(State|States|Action|Actions)/.test(source) || + /\.server\.createServer(States|Actions)/.test(source); if (hasServerCalls) { try { const ast = parser.parse(source, { @@ -68,21 +133,45 @@ export const esbuildPluginPlatformInject = ( const methodName = callExpr.callee.property.name; - // Remove entire variable declarations for createServerState/createServerStates + // Check for both old API (direct) and new API (namespaced) + // Old API: moduleAPI.createServerStates({...}) + // New API: moduleAPI.server.createServerStates({...}) + let isServerMethod = false; + let isServerStateMethod = false; + let isServerActionMethod = false; + + // Check if this is the old API pattern if (methodName === 'createServerState' || methodName === 'createServerStates') { - const object = callExpr.callee.object; - if ( - object.type === 'MemberExpression' && - object.property.type === 'Identifier' && - object.property.name === 'statesAPI' - ) { - nodesToRemove.push(path); + isServerMethod = true; + isServerStateMethod = true; + } else if (methodName === 'createServerAction' || methodName === 'createServerActions') { + isServerMethod = true; + isServerActionMethod = true; + } + // Check if this is the new namespaced API pattern + else if (methodName === 'createServerStates' || methodName === 'createServerActions') { + // Check if the call is on moduleAPI.server.* + if (callExpr.callee.object.type === 'MemberExpression' && + callExpr.callee.object.property.type === 'Identifier' && + callExpr.callee.object.property.name === 'server') { + isServerMethod = true; + if (methodName === 'createServerStates') { + isServerStateMethod = true; + } else if (methodName === 'createServerActions') { + isServerActionMethod = true; + } } } - // For createServerAction/createServerActions, strip function bodies - if (methodName === 'createServerAction' || methodName === 'createServerActions') { + if (!isServerMethod) return; + // Remove entire variable declarations for createServerState/createServerStates + if (isServerStateMethod) { + nodesToRemove.push(path); + } + + // For createServerAction/createServerActions, strip function bodies + if (isServerActionMethod) { // For createServerAction (singular), handle the pattern: createServerAction(key, config, handler) // The handler is the 3rd argument (index 2) or could be in the 2nd if config is omitted if (methodName === 'createServerAction') { diff --git a/packages/springboard/core/engine/client_api.ts b/packages/springboard/core/engine/client_api.ts new file mode 100644 index 00000000..22664f3f --- /dev/null +++ b/packages/springboard/core/engine/client_api.ts @@ -0,0 +1,100 @@ +import type {ActionCallback, ActionCallOptions} from './module_api'; + +/** + * Client API - Methods for creating client actions that the server can invoke. + * + * **Direction:** Server → Client RPC + * + * **Use Cases:** Push notifications, toast messages, UI updates, progress indicators. + * + * **Note:** This is for server-to-client calls. For client-to-server, use shared actions. + */ +export class ClientAPI { + constructor( + private createActionFn: < + Args extends undefined | object, + ReturnValue extends Promise + >( + actionName: string, + options: object, + cb: undefined extends Args ? ActionCallback : ActionCallback + ) => undefined extends Args ? ((args?: Args, options?: ActionCallOptions) => ReturnValue) : ((args: Args, options?: ActionCallOptions) => ReturnValue) + ) {} + + /** + * Create client actions that the server can invoke via RPC. + * + * **Pattern:** Server → Client communication + * + * **Timeout:** Default 5 seconds. Returns `{error: 'timed out'}` on timeout (does not throw). + * + * **Call Modes:** + * - Specific user: `clientActions.toast({...}, userContext)` + * - Broadcast to all: `clientActions.toast({...}, {mode: 'broadcast'})` + * - Broadcast except current: `clientActions.toast({...}, {mode: 'broadcast_exclude_current_user', userContext})` + * - Local (RN → WebView): `clientActions.toast({...}, {mode: 'local'})` + * + * **Idempotency:** Pass server-generated IDs to update existing UI elements + * + * @example + * ```typescript + * // Define client actions + * const clientActions = moduleAPI.client.createClientActions({ + * toast: async (args: {message: string, id?: string, type?: 'info' | 'success' | 'error'}) => { + * const notifId = args.id || notifications.show({ + * message: args.message, + * color: args.type === 'error' ? 'red' : 'blue' + * }); + * return {toastId: notifId}; + * }, + * + * updateProgress: async (args: {operationId: string, progress: number}) => { + * setProgress(args.operationId, args.progress); + * } + * }); + * + * // Server calls client action + * const serverActions = moduleAPI.server.createServerActions({ + * doWork: async (args, userContext) => { + * const toastId = uuid(); + * + * // Initial notification + * clientActions.toast({message: 'Starting...', id: toastId}, userContext); + * + * await doWork(); + * + * // Update same notification + * clientActions.toast({message: 'Complete!', id: toastId, type: 'success'}, userContext); + * } + * }); + * ``` + * + * @see {@link https://docs.springboard.dev/client-actions | Client Actions Guide} + */ + createClientActions = >>( + actions: Actions + ): { [K in keyof Actions]: undefined extends Parameters[0] ? ((payload?: Parameters[0], options?: ActionCallOptions) => Promise>) : ((payload: Parameters[0], options?: ActionCallOptions) => Promise>) } => { + const keys = Object.keys(actions); + + for (const key of keys) { + (actions[key] as ActionCallback) = this.createActionFn(key, {}, actions[key]); + } + + return actions; + }; + + /** + * Create a single client action. + * + * @see {@link createClientActions} for batch creation (recommended). + */ + createClientAction = < + Args extends undefined | object, + ReturnValue extends Promise + >( + actionName: string, + cb: undefined extends Args ? ActionCallback : ActionCallback + ): undefined extends Args ? ((args?: Args, options?: ActionCallOptions) => ReturnValue) : ((args: Args, options?: ActionCallOptions) => ReturnValue) => { + return this.createActionFn(actionName, {}, cb); + }; +} diff --git a/packages/springboard/core/engine/module_api.spec.ts b/packages/springboard/core/engine/module_api.spec.ts index 9d45a5f5..10081ea2 100644 --- a/packages/springboard/core/engine/module_api.spec.ts +++ b/packages/springboard/core/engine/module_api.spec.ts @@ -15,9 +15,11 @@ describe('ModuleAPI', () => { await engine.initialize(); const mod = await engine.registerModule('TestModule', {}, async (moduleAPI) => { - const state = await moduleAPI.statesAPI.createSharedState('hey', {yep: 'yeah'}); + const states = await moduleAPI.shared.createSharedStates({ + hey: {yep: 'yeah'} + }); return { - state, + state: states.hey, }; }); diff --git a/packages/springboard/core/engine/module_api.ts b/packages/springboard/core/engine/module_api.ts index bb9fecc2..f3ad186a 100644 --- a/packages/springboard/core/engine/module_api.ts +++ b/packages/springboard/core/engine/module_api.ts @@ -2,6 +2,11 @@ import {ServerStateSupervisor, SharedStateSupervisor, StateSupervisor, UserAgent import {ExtraModuleDependencies, Module, NavigationItemConfig, RegisteredRoute} from 'springboard/module_registry/module_registry'; import {CoreDependencies, ModuleDependencies} from '../types/module_types'; import {RegisterRouteOptions} from './register'; +import {ServerAPI} from './server_api'; +import {SharedAPI} from './shared_api'; +import {UserAgentAPI} from './user_agent_api'; +import {ClientAPI} from './client_api'; +import {UIAPI} from './ui_api'; type ActionConfigOptions = object; @@ -12,7 +17,7 @@ export type ActionCallOptions = { /** * The Action callback */ -type ActionCallback = Promise> = (args: Args, options?: ActionCallOptions) => ReturnValue; +export type ActionCallback = Promise> = (args: Args, options?: ActionCallOptions) => ReturnValue; // this would make it so modules/plugins can extend the module API dynamically through interface merging // export interface ModuleAPI { @@ -42,17 +47,48 @@ export class ModuleAPI { try { cb(); } catch (e) { - console.error('destroy callback failed in StatesAPI', e); + console.error('destroy callback failed', e); } } - - this.statesAPI.destroy(); }; - 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) { + // Store deps for backwards compatibility this.deps = {core: coreDeps, module: modDeps, extra: extraDeps}; + + // Initialize namespace APIs + this.server = new ServerAPI( + this.fullPrefix, + this.coreDeps, + this.modDeps, + this.createAction.bind(this), + this.onDestroy + ); + + this.shared = new SharedAPI( + this.fullPrefix, + this.coreDeps, + this.modDeps, + this.createAction.bind(this), + this.onDestroy + ); + + this.userAgent = new UserAgentAPI( + this.fullPrefix, + this.coreDeps, + this.modDeps, + this.createAction.bind(this), + this.onDestroy + ); + + this.client = new ClientAPI( + this.createAction.bind(this) + ); + + this.ui = new UIAPI( + this.module, + this.modDeps + ); } public readonly moduleId = this.module.moduleId; @@ -60,109 +96,106 @@ export class ModuleAPI { public readonly fullPrefix = `${this.prefix}|module|${this.module.moduleId}`; /** - * Create shared and persistent pieces of state, scoped to this specific module. - */ - public readonly statesAPI = new StatesAPI(this.fullPrefix, this.coreDeps, this.modDeps); + * Dependencies for this module (core framework deps and module-specific deps). + */ + public readonly deps: {core: CoreDependencies; module: ModuleDependencies, extra: ExtraModuleDependencies}; - getModule = this.modDeps.moduleRegistry.getModule.bind(this.modDeps.moduleRegistry); + /** + * Server-only states and actions (stripped from client builds). + * + * @see {@link ServerAPI} + */ + public readonly server: ServerAPI; /** - * Register a route with the application's React Router. More info in [registering UI routes](/springboard/registering-ui). + * Shared states and actions (synced across all clients). * - * ```jsx - // matches "" and "/" - * moduleAPI.registerRoute('/', () => { - * return ( - *
- * ); - * }); + * @see {@link SharedAPI} + */ + public readonly shared: SharedAPI; + + /** + * User agent (device-local) states and actions. + * + * @see {@link UserAgentAPI} + */ + public readonly userAgent: UserAgentAPI; + + /** + * Client actions that server can invoke (server→client RPC). + * + * @see {@link ClientAPI} + */ + public readonly client: ClientAPI; + + /** + * UI-related methods (routes, application shell, providers). * - * // matches "/modules/MyModule" - * moduleAPI.registerRoute('', () => { - * return ( - *
- * ); - * }); + * @see {@link UIAPI} + */ + public readonly ui: UIAPI; + + /** + * Get another module by its ID. * + * @example + * ```typescript + * const macroModule = moduleAPI.getModule('midi_macro'); * ``` + */ + getModule = this.modDeps.moduleRegistry.getModule.bind(this.modDeps.moduleRegistry); + + /** + * Register a route with the application's React Router. * + * @deprecated Use `moduleAPI.ui.registerRoute()` instead. + * + * @see {@link UIAPI.registerRoute} */ registerRoute = (routePath: string, options: RegisterRouteOptions, component: RegisteredRoute['component']) => { - const routes = this.module.routes || {}; - routes[routePath] = { - options, - component, - }; - - this.module.routes = {...routes}; - if (this.modDeps.moduleRegistry.getCustomModule(this.module.moduleId)) { - this.modDeps.moduleRegistry.refreshModules(); - } + this.ui.registerRoute(routePath, options, component); }; + /** + * Register an application shell component. + * + * @deprecated Use `moduleAPI.ui.registerApplicationShell()` instead. + * + * @see {@link UIAPI.registerApplicationShell} + */ registerApplicationShell = (component: React.ElementType>) => { - this.module.applicationShell = component; + this.ui.registerApplicationShell(component); }; + /** + * @deprecated Use `moduleAPI.shared.createSharedStates()` instead. + */ createSharedStates = async >(states: States): Promise<{[K in keyof States]: StateSupervisor}> => { - const keys = Object.keys(states); - const promises = keys.map(async key => { - return { - state: await this.statesAPI.createSharedState(key, states[key]), - key, - }; - }); - - const result = {} as {[K in keyof States]: StateSupervisor}; - - const supervisors = await Promise.all(promises); - for (const key of keys) { - (result[key] as StateSupervisor) = supervisors.find(s => s.key === key as any)!.state; - } - - return result; + return this.shared.createSharedStates(states); }; + /** + * @deprecated Use `moduleAPI.shared.createSharedStates()` instead. + */ createStates = this.createSharedStates; + /** + * @deprecated Use `moduleAPI.server.createServerStates()` instead. + */ createServerStates = async >(states: States): Promise<{[K in keyof States]: StateSupervisor}> => { - const keys = Object.keys(states); - const promises = keys.map(async key => { - return { - state: await this.statesAPI.createServerState(key, states[key]), - key, - }; - }); - - const result = {} as {[K in keyof States]: StateSupervisor}; - - const supervisors = await Promise.all(promises); - for (const key of keys) { - (result[key] as StateSupervisor) = supervisors.find(s => s.key === key as any)!.state; - } - - return result; + return this.server.createServerStates(states); }; + /** + * @deprecated Use `moduleAPI.userAgent.createUserAgentStates()` instead. + */ createUserAgentStates = async >(states: States): Promise<{[K in keyof States]: StateSupervisor}> => { - const keys = Object.keys(states); - const promises = keys.map(async key => { - return { - state: await this.statesAPI.createUserAgentState(key, states[key]), - key, - }; - }); - - const result = {} as {[K in keyof States]: StateSupervisor}; - - const supervisors = await Promise.all(promises); - for (const key of keys) { - (result[key] as StateSupervisor) = supervisors.find(s => s.key === key as any)!.state; - } - - return result; + return this.userAgent.createUserAgentStates(states); }; + /** + * @deprecated Use `moduleAPI.shared.createSharedActions()` instead. + */ createActions = >>( actions: Actions ): { [K in keyof Actions]: undefined extends Parameters[0] ? ((payload?: Parameters[0], options?: ActionCallOptions) => Promise>) : ((payload: Parameters[0], options?: ActionCallOptions) => Promise>) } => { @@ -176,9 +209,8 @@ export class ModuleAPI { }; /** - * Create a server-only action that runs exclusively on the server. - * In client builds, the implementation will be stripped out, leaving only the RPC call structure. - */ + * @deprecated Use `moduleAPI.server.createServerAction()` instead. + */ createServerAction = < Options extends ActionConfigOptions, Args extends undefined | object, @@ -192,9 +224,8 @@ export class ModuleAPI { }; /** - * Create multiple server-only actions that run exclusively on the server. - * In client builds, the implementations will be stripped out, leaving only the RPC call structure. - */ + * @deprecated Use `moduleAPI.server.createServerActions()` instead. + */ createServerActions = >>( actions: Actions ): { [K in keyof Actions]: undefined extends Parameters[0] ? ((payload?: Parameters[0], options?: ActionCallOptions) => Promise>) : ((payload: Parameters[0], options?: ActionCallOptions) => Promise>) } => { diff --git a/packages/springboard/core/engine/server_api.ts b/packages/springboard/core/engine/server_api.ts new file mode 100644 index 00000000..47dd172c --- /dev/null +++ b/packages/springboard/core/engine/server_api.ts @@ -0,0 +1,168 @@ +import {ServerStateSupervisor, StateSupervisor} from '../services/states/shared_state_service'; +import {CoreDependencies, ModuleDependencies} from '../types/module_types'; +import type {ActionCallback, ActionCallOptions} from './module_api'; + +type ActionConfigOptions = object; + +/** + * Server API - Methods for creating server-only states and actions. + * + * **Security:** All server states and action implementations are stripped from client builds. + * + * **Visibility:** The `server` namespace makes it obvious during code review that + * this code contains sensitive logic that should never reach the client. + */ +export class ServerAPI { + constructor( + private prefix: string, + private coreDeps: CoreDependencies, + private modDeps: ModuleDependencies, + private createActionFn: < + Options extends ActionConfigOptions, + Args extends undefined | object, + ReturnValue extends Promise + >( + actionName: string, + options: Options, + cb: undefined extends Args ? ActionCallback : ActionCallback + ) => undefined extends Args ? ((args?: Args, options?: ActionCallOptions) => ReturnValue) : ((args: Args, options?: ActionCallOptions) => ReturnValue), + private onDestroyFn: (cb: Function) => void + ) {} + + /** + * Create server-only states that are never synced to clients. + * + * **Security:** State values are only accessible server-side. In client builds, + * the entire variable declaration is removed by the compiler. + * + * **Storage:** Persisted to server storage (database/filesystem). + * + * **Sync:** Never synced to clients. Use `shared.createSharedStates()` for synced state. + * + * **Build:** Entire variable declarations are removed from client builds by detecting + * the method name `createServerStates`. + * + * @example + * ```typescript + * const serverStates = await moduleAPI.server.createServerStates({ + * apiKey: process.env.STRIPE_KEY, + * dbPassword: process.env.DB_PASSWORD, + * internalCache: {lastSync: Date.now()} + * }); + * + * // In server action + * const key = serverStates.apiKey.getState(); + * ``` + * + * @see {@link https://docs.springboard.dev/server-states | Server States Guide} + */ + createServerStates = async >( + states: States + ): Promise<{[K in keyof States]: StateSupervisor}> => { + const keys = Object.keys(states); + const promises = keys.map(async key => { + return { + state: await this.createServerState(key, states[key]), + key, + }; + }); + + const result = {} as {[K in keyof States]: StateSupervisor}; + + const supervisors = await Promise.all(promises); + for (const key of keys) { + (result[key] as StateSupervisor) = supervisors.find(s => s.key === key as any)!.state; + } + + return result; + }; + + /** + * Create a single server-only state. + * + * @see {@link createServerStates} for batch creation (recommended). + */ + private createServerState = async (stateName: string, initialValue: State): Promise> => { + const fullKey = `${this.prefix}|state.server|${stateName}`; + + // Check cache first (populated during serverStateService.initialize()) + const cachedValue = this.modDeps.services.serverStateService.getCachedValue(fullKey) as State | undefined; + if (cachedValue !== undefined) { + initialValue = cachedValue; + } else { + const storedValue = await this.coreDeps.storage.server.get(fullKey); + if (storedValue !== null && storedValue !== undefined) { + initialValue = storedValue; + } else if (this.coreDeps.isMaestro()) { + await this.coreDeps.storage.server.set(fullKey, initialValue); + } + } + + const supervisor = new ServerStateSupervisor(fullKey, initialValue); + + // Subscribe to persist changes to storage, but do not broadcast to clients + const sub = supervisor.subjectForKVStorePublish.subscribe(async value => { + await this.coreDeps.storage.server.set(fullKey, value); + }); + this.onDestroyFn(sub.unsubscribe); + + return supervisor; + }; + + /** + * Create multiple server-only actions that run exclusively on the server. + * + * **Security:** In client builds, the action implementations are stripped out, + * leaving only the RPC call structure. The method name `createServerActions` + * is detected by the compiler. + * + * **Usage:** Server actions can access server states and perform sensitive operations. + * + * @example + * ```typescript + * const serverActions = moduleAPI.server.createServerActions({ + * authenticate: async (args: {username: string, password: string}) => { + * const session = serverStates.sessions.getState(); + * // Validate credentials + * return {authenticated: true, token: generateToken()}; + * }, + * + * processPayment: async (args: {amount: number, customerId: string}) => { + * const apiKey = serverStates.apiKey.getState(); + * // Process payment with API key + * return {success: true, transactionId: '...'}; + * } + * }); + * ``` + * + * @see {@link https://docs.springboard.dev/server-actions | Server Actions Guide} + */ + createServerActions = >>( + actions: Actions + ): { [K in keyof Actions]: undefined extends Parameters[0] ? ((payload?: Parameters[0], options?: ActionCallOptions) => Promise>) : ((payload: Parameters[0], options?: ActionCallOptions) => Promise>) } => { + const keys = Object.keys(actions); + + for (const key of keys) { + (actions[key] as ActionCallback) = this.createActionFn(key, {}, actions[key]); + } + + return actions; + }; + + /** + * Create a single server-only action. + * + * @see {@link createServerActions} for batch creation (recommended). + */ + createServerAction = < + Options extends ActionConfigOptions, + Args extends undefined | object, + ReturnValue extends Promise + >( + actionName: string, + options: Options, + cb: undefined extends Args ? ActionCallback : ActionCallback + ): undefined extends Args ? ((args?: Args, options?: ActionCallOptions) => ReturnValue) : ((args: Args, options?: ActionCallOptions) => ReturnValue) => { + return this.createActionFn(actionName, options, cb); + }; +} diff --git a/packages/springboard/core/engine/shared_api.ts b/packages/springboard/core/engine/shared_api.ts new file mode 100644 index 00000000..d0d8cca2 --- /dev/null +++ b/packages/springboard/core/engine/shared_api.ts @@ -0,0 +1,175 @@ +import {SharedStateSupervisor, StateSupervisor} from '../services/states/shared_state_service'; +import {CoreDependencies, ModuleDependencies} from '../types/module_types'; +import type {ActionCallback, ActionCallOptions} from './module_api'; + +type ActionConfigOptions = object; + +/** + * Shared API - Methods for creating states and actions that are shared across all clients and the server. + * + * **Sync:** Shared states are synchronized across all connected clients via WebSockets. + * + * **Source of Truth:** The server is the authoritative source. Client changes are sent to + * the server and then broadcast to all clients. + */ +export class SharedAPI { + constructor( + private prefix: string, + private coreDeps: CoreDependencies, + private modDeps: ModuleDependencies, + private createActionFn: < + Options extends ActionConfigOptions, + Args extends undefined | object, + ReturnValue extends Promise + >( + actionName: string, + options: Options, + cb: undefined extends Args ? ActionCallback : ActionCallback + ) => undefined extends Args ? ((args?: Args, options?: ActionCallOptions) => ReturnValue) : ((args: Args, options?: ActionCallOptions) => ReturnValue), + private onDestroyFn: (cb: Function) => void + ) {} + + /** + * Create shared states that sync across all connected clients and the server. + * + * **Storage:** Persisted to server storage and synced in-memory on all clients. + * + * **Sync:** Changes are automatically synchronized via WebSockets. When any client + * or the server updates the state, all connected clients receive the update. + * + * **Source of Truth:** Server is authoritative. Client changes are sent to server first, + * then broadcast to all clients. + * + * **Use Cases:** Game state, collaborative editing, real-time dashboards, shared settings. + * + * @example + * ```typescript + * const sharedStates = await moduleAPI.shared.createSharedStates({ + * board: [[null, null, null], [null, null, null], [null, null, null]], + * currentPlayer: 'X', + * winner: null, + * score: {X: 0, O: 0} + * }); + * + * // Update from any client or server + * sharedStates.board.setState(newBoard); + * + * // Use in React component + * const board = sharedStates.board.useState(); + * ``` + * + * @see {@link https://docs.springboard.dev/shared-states | Shared States Guide} + */ + createSharedStates = async >( + states: States + ): Promise<{[K in keyof States]: StateSupervisor}> => { + const keys = Object.keys(states); + const promises = keys.map(async key => { + return { + state: await this.createSharedState(key, states[key]), + key, + }; + }); + + const result = {} as {[K in keyof States]: StateSupervisor}; + + const supervisors = await Promise.all(promises); + for (const key of keys) { + (result[key] as StateSupervisor) = supervisors.find(s => s.key === key as any)!.state; + } + + return result; + }; + + /** + * Create a single shared state. + * + * @see {@link createSharedStates} for batch creation (recommended). + */ + private createSharedState = async (stateName: string, initialValue: State): Promise> => { + const fullKey = `${this.prefix}|state.shared|${stateName}`; + + const cachedValue = this.modDeps.services.remoteSharedStateService.getCachedValue(fullKey) as State | undefined; + if (cachedValue !== undefined) { + initialValue = cachedValue; + } else { + const storedValue = await this.coreDeps.storage.shared.get(fullKey); + if (storedValue !== null && storedValue !== undefined) { + initialValue = storedValue; + } else if (this.coreDeps.isMaestro()) { + await this.coreDeps.storage.shared.set(fullKey, initialValue); + } + } + + const supervisor = new SharedStateSupervisor(fullKey, initialValue, this.modDeps.services.remoteSharedStateService); + + const sub = supervisor.subjectForKVStorePublish.subscribe(async value => { + await this.coreDeps.storage.shared.set(fullKey, value); + }); + this.onDestroyFn(sub.unsubscribe); + + return supervisor; + }; + + /** + * Create shared actions that can run locally or remotely. + * + * **Execution:** By default, actions run on the server via RPC. If called from the server + * or with `{mode: 'local'}`, the action runs locally. + * + * **Use Cases:** Business logic, state updates, validation, data fetching. + * + * @example + * ```typescript + * const sharedActions = moduleAPI.shared.createSharedActions({ + * clickCell: async (args: {row: number, col: number}) => { + * const board = sharedStates.board.getState(); + * if (board[args.row][args.col]) return; // Cell already filled + * + * sharedStates.board.setStateImmer(draft => { + * draft[args.row][args.col] = sharedStates.currentPlayer.getState(); + * }); + * + * // Check for winner + * const winner = checkWinner(sharedStates.board.getState()); + * if (winner) sharedStates.winner.setState(winner); + * }, + * + * resetGame: async () => { + * sharedStates.board.setState(initialBoard); + * sharedStates.winner.setState(null); + * } + * }); + * ``` + * + * @see {@link https://docs.springboard.dev/shared-actions | Shared Actions Guide} + */ + createSharedActions = >>( + actions: Actions + ): { [K in keyof Actions]: undefined extends Parameters[0] ? ((payload?: Parameters[0], options?: ActionCallOptions) => Promise>) : ((payload: Parameters[0], options?: ActionCallOptions) => Promise>) } => { + const keys = Object.keys(actions); + + for (const key of keys) { + (actions[key] as ActionCallback) = this.createActionFn(key, {}, actions[key]); + } + + return actions; + }; + + /** + * Create a single shared action. + * + * @see {@link createSharedActions} for batch creation (recommended). + */ + createSharedAction = < + Options extends ActionConfigOptions, + Args extends undefined | object, + ReturnValue extends Promise + >( + actionName: string, + options: Options, + cb: undefined extends Args ? ActionCallback : ActionCallback + ): undefined extends Args ? ((args?: Args, options?: ActionCallOptions) => ReturnValue) : ((args: Args, options?: ActionCallOptions) => ReturnValue) => { + return this.createActionFn(actionName, options, cb); + }; +} diff --git a/packages/springboard/core/engine/ui_api.ts b/packages/springboard/core/engine/ui_api.ts new file mode 100644 index 00000000..4ab8cd9f --- /dev/null +++ b/packages/springboard/core/engine/ui_api.ts @@ -0,0 +1,127 @@ +import React from 'react'; +import {Module, RegisteredRoute} from 'springboard/module_registry/module_registry'; +import {ModuleDependencies} from '../types/module_types'; +import {RegisterRouteOptions} from './register'; + +/** + * UI API - Methods for registering UI components and routes. + * + * **Scope:** Client-side only (not available on server builds without UI). + * + * **React Router:** Routes are registered with React Router for client-side navigation. + */ +export class UIAPI { + constructor( + private module: Module, + private modDeps: ModuleDependencies + ) {} + + /** + * Register a route with the application's React Router. + * + * **Path Matching:** + * - Empty string `''` or `'/'` matches the module's root path + * - Relative paths are scoped to the module (e.g., `'/settings'` → `'/modules/MyModule/settings'`) + * + * @example + * ```typescript + * // Matches "/modules/MyModule" and "/modules/MyModule/" + * moduleAPI.ui.registerRoute('/', {}, () => { + * return
Home
; + * }); + * + * // Matches "/modules/MyModule/settings" + * moduleAPI.ui.registerRoute('/settings', {}, () => { + * return
Settings
; + * }); + * + * // Hide application shell (full-screen route) + * moduleAPI.ui.registerRoute('/fullscreen', {hideApplicationShell: true}, () => { + * return
Fullscreen Content
; + * }); + * ``` + * + * @see {@link https://docs.springboard.dev/ui-routes | UI Routes Guide} + */ + registerRoute = ( + routePath: string, + options: RegisterRouteOptions, + component: RegisteredRoute['component'] + ): void => { + const routes = this.module.routes || {}; + routes[routePath] = { + options, + component, + }; + + this.module.routes = {...routes}; + if (this.modDeps.moduleRegistry.getCustomModule(this.module.moduleId)) { + this.modDeps.moduleRegistry.refreshModules(); + } + }; + + /** + * Register an application shell component that wraps all routes. + * + * **Purpose:** Provide consistent layout, navigation, and styling around all module routes. + * + * **Props:** Receives `{modules: Module[], children: React.ReactNode}` + * + * @example + * ```typescript + * moduleAPI.ui.registerApplicationShell(({modules, children}) => { + * return ( + *
+ *
+ * + *
+ *
{children}
+ *
© 2025
+ *
+ * ); + * }); + * ``` + * + * @see {@link https://docs.springboard.dev/application-shell | Application Shell Guide} + */ + registerApplicationShell = ( + component: React.ElementType> + ): void => { + this.module.applicationShell = component; + }; + + /** + * Register a React context provider that wraps the entire application. + * + * **Purpose:** Provide global context (theme, auth, etc.) to all components. + * + * **Future:** This method is planned but not yet implemented. + * + * @example + * ```typescript + * moduleAPI.ui.registerReactProvider(({children}) => { + * return ( + * + * + * {children} + * + * + * ); + * }); + * ``` + * + * @see {@link https://docs.springboard.dev/react-providers | React Providers Guide} + */ + registerReactProvider = ( + _provider: React.ComponentType<{children: React.ReactNode}> + ): void => { + // TODO: Implement provider registration + throw new Error('registerReactProvider is not yet implemented'); + }; +} diff --git a/packages/springboard/core/engine/user_agent_api.ts b/packages/springboard/core/engine/user_agent_api.ts new file mode 100644 index 00000000..fa0852fa --- /dev/null +++ b/packages/springboard/core/engine/user_agent_api.ts @@ -0,0 +1,181 @@ +import {SharedStateSupervisor, StateSupervisor} from '../services/states/shared_state_service'; +import {CoreDependencies, ModuleDependencies} from '../types/module_types'; +import type {ActionCallback, ActionCallOptions} from './module_api'; + +type ActionConfigOptions = object; + +/** + * UserAgent API - Methods for creating device-local states and actions. + * + * **Storage:** Stored on the device (browser localStorage, React Native AsyncStorage, etc.) + * + * **Scope:** Each device has its own independent copy. Not synchronized across devices. + * + * **React Native:** Runs in the React Native process, not the WebView process. + */ +export class UserAgentAPI { + constructor( + private prefix: string, + private coreDeps: CoreDependencies, + private modDeps: ModuleDependencies, + private createActionFn: < + Options extends ActionConfigOptions, + Args extends undefined | object, + ReturnValue extends Promise + >( + actionName: string, + options: Options, + cb: undefined extends Args ? ActionCallback : ActionCallback + ) => undefined extends Args ? ((args?: Args, options?: ActionCallOptions) => ReturnValue) : ((args: Args, options?: ActionCallOptions) => ReturnValue), + private onDestroyFn: (cb: Function) => void + ) {} + + /** + * Create user agent states that are stored locally on the device. + * + * **Storage:** Browser localStorage, React Native AsyncStorage, or equivalent. + * + * **Sync:** Not synchronized across devices. Each device maintains its own copy. + * + * **Persistence:** Survives app restarts. Data is tied to the device, not the user account. + * + * **Use Cases:** User preferences, UI state, local cache, device-specific settings. + * + * **React Native:** Stored in the React Native process, not the WebView. + * + * @example + * ```typescript + * const userAgentStates = await moduleAPI.userAgent.createUserAgentStates({ + * theme: 'dark', + * sidebarCollapsed: false, + * lastViewedPage: '/dashboard', + * volume: 0.8 + * }); + * + * // Update locally + * userAgentStates.theme.setState('light'); + * + * // Use in React + * const theme = userAgentStates.theme.useState(); + * ``` + * + * @see {@link https://docs.springboard.dev/useragent-states | UserAgent States Guide} + */ + createUserAgentStates = async >( + states: States + ): Promise<{[K in keyof States]: StateSupervisor}> => { + const keys = Object.keys(states); + const promises = keys.map(async key => { + return { + state: await this.createUserAgentState(key, states[key]), + key, + }; + }); + + const result = {} as {[K in keyof States]: StateSupervisor}; + + const supervisors = await Promise.all(promises); + for (const key of keys) { + (result[key] as StateSupervisor) = supervisors.find(s => s.key === key as any)!.state; + } + + return result; + }; + + /** + * Create a single user agent state. + * + * @see {@link createUserAgentStates} for batch creation (recommended). + */ + private createUserAgentState = async (stateName: string, initialValue: State): Promise> => { + const fullKey = `${this.prefix}|state.useragent|${stateName}`; + + if (this.modDeps.services.localSharedStateService) { + const cachedValue = this.modDeps.services.localSharedStateService.getCachedValue(fullKey) as State | undefined; + if (cachedValue !== undefined) { + initialValue = cachedValue; + } else { + const storedValue = await this.coreDeps.storage.userAgent.get(fullKey); + if (storedValue !== null && storedValue !== undefined) { + initialValue = storedValue; + } + } + } + + const supervisor = new SharedStateSupervisor(fullKey, initialValue, this.modDeps.services.localSharedStateService); + + const sub = supervisor.subjectForKVStorePublish.subscribe(async value => { + await this.coreDeps.storage.userAgent.set(fullKey, value); + }); + this.onDestroyFn(sub.unsubscribe); + + // Warn if accessed on server (rarely needed) + if (this.coreDeps.isMaestro && this.coreDeps.isMaestro()) { + const originalGetState = supervisor.getState.bind(supervisor); + supervisor.getState = () => { + console.warn( + `UserAgent state '${stateName}' accessed on server. This is rarely needed.\n` + + 'Consider using server state, or check springboard.isPlatform(\'client\') before accessing.' + ); + return originalGetState(); + }; + } + + return supervisor; + }; + + /** + * Create user agent actions that run on the device. + * + * **Execution:** Runs locally on the device. In React Native, runs in the RN process + * (not the WebView). + * + * **Use Cases:** Device-specific operations, local UI updates, accessing device APIs. + * + * **React Native:** Call native modules, access device features (camera, vibration, etc.) + * + * @example + * ```typescript + * const userAgentActions = moduleAPI.userAgent.createUserAgentActions({ + * vibrate: async (args: {duration: number}) => { + * // React Native - runs in RN process + * Vibration.vibrate(args.duration); + * }, + * + * updateLocalPreference: async (args: {key: string, value: any}) => { + * userAgentStates[args.key].setState(args.value); + * } + * }); + * ``` + * + * @see {@link https://docs.springboard.dev/useragent-actions | UserAgent Actions Guide} + */ + createUserAgentActions = >>( + actions: Actions + ): { [K in keyof Actions]: undefined extends Parameters[0] ? ((payload?: Parameters[0], options?: ActionCallOptions) => Promise>) : ((payload: Parameters[0], options?: ActionCallOptions) => Promise>) } => { + const keys = Object.keys(actions); + + for (const key of keys) { + (actions[key] as ActionCallback) = this.createActionFn(key, {}, actions[key]); + } + + return actions; + }; + + /** + * Create a single user agent action. + * + * @see {@link createUserAgentActions} for batch creation (recommended). + */ + createUserAgentAction = < + Options extends ActionConfigOptions, + Args extends undefined | object, + ReturnValue extends Promise + >( + actionName: string, + options: Options, + cb: undefined extends Args ? ActionCallback : ActionCallback + ): undefined extends Args ? ((args?: Args, options?: ActionCallOptions) => ReturnValue) : ((args: Args, options?: ActionCallOptions) => ReturnValue) => { + return this.createActionFn(actionName, options, cb); + }; +} diff --git a/packages/springboard/core/modules/files/files_module.tsx b/packages/springboard/core/modules/files/files_module.tsx index 5d175a99..93da4d7e 100644 --- a/packages/springboard/core/modules/files/files_module.tsx +++ b/packages/springboard/core/modules/files/files_module.tsx @@ -41,14 +41,16 @@ type FilesModule = { } springboard.registerModule('Files', {}, async (moduleAPI): Promise => { - const allStoredFiles = await moduleAPI.statesAPI.createSharedState('allStoredFiles', []); + const sharedStates = await moduleAPI.shared.createSharedStates({ + allStoredFiles: [] as FileInfo[] + }); const fileUploader = new IndexedDbFileStorageProvider(); await fileUploader.initialize(); const uploadFile = async (file: File): Promise => { const fileInfo = await fileUploader.uploadFile(file); - allStoredFiles.setState(files => [...files, fileInfo]); + sharedStates.allStoredFiles.setState((files: FileInfo[]) => [...files, fileInfo]); return fileInfo; }; @@ -60,7 +62,7 @@ springboard.registerModule('Files', {}, async (moduleAPI): Promise ): any => { return async (file: File, args: T) => { const fileInfo = await fileUploader.uploadFile(file); - allStoredFiles.setState(files => [...files, fileInfo]); + sharedStates.allStoredFiles.setState((files: FileInfo[]) => [...files, fileInfo]); callback(fileInfo, args); // return fileUploader.uploadFile(modAPI, file, args, actionName, options); @@ -69,8 +71,8 @@ springboard.registerModule('Files', {}, async (moduleAPI): Promise const deleteFile = async (fileId: string) => { await fileUploader.deleteFile(fileId); - allStoredFiles.setState(files => { - const index = files.findIndex(f => f.id === fileId)!; + sharedStates.allStoredFiles.setState((files: FileInfo[]) => { + const index = files.findIndex((f: FileInfo) => f.id === fileId)!; return [ ...files.slice(0, index), ...files.slice(index + 1), @@ -83,7 +85,7 @@ springboard.registerModule('Files', {}, async (moduleAPI): Promise createFileUploadAction, deleteFile, getFileSrc: fileUploader.getFileContent, - listFiles: allStoredFiles.getState, - useFiles: allStoredFiles.useState, + listFiles: sharedStates.allStoredFiles.getState, + useFiles: sharedStates.allStoredFiles.useState, }; }); diff --git a/packages/springboard/platforms/react-native/services/kv/kv_rn_and_webview.spec.tsx b/packages/springboard/platforms/react-native/services/kv/kv_rn_and_webview.spec.tsx index b748c59b..e57d0e20 100644 --- a/packages/springboard/platforms/react-native/services/kv/kv_rn_and_webview.spec.tsx +++ b/packages/springboard/platforms/react-native/services/kv/kv_rn_and_webview.spec.tsx @@ -29,16 +29,18 @@ describe('KvRnWebview', () => { const entrypoint = (sb: SpringboardRegistry | Springboard) => { sb.registerModule('Test', {}, async (m) => { - const myUserAgentState = await m.statesAPI.createUserAgentState('myUserAgentState', {message: 'Hey'}); + const userAgentStates = await m.userAgent.createUserAgentStates({ + myUserAgentState: {message: 'Hey'} + }); const actions = m.createActions({ changeValue: async (args: {value: string}) => { - myUserAgentState.setState({message: args.value}); + userAgentStates.myUserAgentState.setState({message: args.value}); }, }); m.registerRoute('/', {}, () => { - const myState = myUserAgentState.useState(); + const myState = userAgentStates.myUserAgentState.useState(); const [localState, setLocalState] = useState(''); @@ -60,7 +62,7 @@ describe('KvRnWebview', () => {