Skip to content

Commit cbeec59

Browse files
committed
add separate createServerState and createServerAction APIs
1 parent 39f9a6f commit cbeec59

30 files changed

+964
-145
lines changed

.node-version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
v20.17.0
1+
v22.20.0

apps/small_apps/app_with_splash_screen/app_with_splash_screen.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ const CustomSplashScreen = () => {
5050
springboard.registerSplashScreen(CustomSplashScreen);
5151

5252
springboard.registerModule('AppWithSplashScreen', {}, async (moduleAPI) => {
53-
const messageState = await moduleAPI.statesAPI.createPersistentState<string>('message', 'Hello from the app with custom splash screen!');
53+
const messageState = await moduleAPI.statesAPI.createSharedState<string>('message', 'Hello from the app with custom splash screen!');
5454

5555
await new Promise(r => setTimeout(r, 5000)); // fake waiting time
5656

apps/small_apps/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,20 @@
22
"name": "small_apps",
33
"version": "0.0.1-autogenerated",
44
"main": "index.js",
5-
"scripts": {
6-
},
5+
"scripts": {},
76
"keywords": [],
87
"author": "",
98
"license": "ISC",
109
"description": "",
1110
"dependencies": {
12-
"springboard": "workspace:*",
1311
"@jamtools/core": "workspace:*",
1412
"@jamtools/features": "workspace:*",
1513
"@springboardjs/platforms-browser": "workspace:*",
1614
"@springboardjs/platforms-node": "workspace:*",
15+
"better-sqlite3": "^11.3.0",
1716
"react": "catalog:",
1817
"react-dom": "catalog:",
18+
"springboard": "workspace:*",
1919
"springboard-cli": "workspace:*"
2020
},
2121
"devDependencies": {
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import React from 'react';
2+
import springboard from 'springboard';
3+
4+
// Test various edge cases for server state and action compilation
5+
6+
springboard.registerModule('server_state_edge_cases', {}, async (moduleAPI) => {
7+
// Test 1: Multiple server states at once using createServerStates
8+
const serverStates = await moduleAPI.statesAPI.createServerStates({
9+
userSession: { userId: 'user-123', token: 'secret-token' },
10+
apiKeys: { stripe: 'sk_test_123', sendgrid: 'SG.xyz' },
11+
internalCache: { lastSync: Date.now(), data: {} },
12+
});
13+
14+
// Test 2: Single server state
15+
const singleServerState = await moduleAPI.statesAPI.createServerState('config', {
16+
dbPassword: 'super-secret-password',
17+
adminKey: 'admin-key-123',
18+
});
19+
20+
// Test 3: Function that returns a function (for actions)
21+
const createHandler = (name: string) => async () => {
22+
console.log(`Handler for ${name} called`);
23+
return { success: true, name };
24+
};
25+
26+
// Test 4: Regular createAction (should be stripped - testing backwards compatibility)
27+
const regularAction1 = moduleAPI.createAction('regular1', {}, async () => {
28+
console.log('Regular action - will be stripped in browser');
29+
return { data: 'regular' };
30+
});
31+
32+
// Test 5: Singular createServerAction with inline function
33+
const serverAction1 = moduleAPI.createServerAction('serverAction1', {}, async () => {
34+
console.log('This should be removed from client');
35+
return serverStates.userSession.getState();
36+
});
37+
38+
// Test 6: Singular createServerAction with function that returns a function
39+
const serverAction2 = moduleAPI.createServerAction('serverAction2', {}, createHandler('test'));
40+
41+
// Test 7: Singular createServerAction with variable reference
42+
const myHandler = async () => {
43+
console.log('Variable handler');
44+
return singleServerState.getState();
45+
};
46+
const serverAction3 = moduleAPI.createServerAction('serverAction3', {}, myHandler);
47+
48+
// Test 8: Mix of createActions (regular - for backwards compat testing)
49+
const regularActions = moduleAPI.createActions({
50+
// Inline arrow function
51+
inlineArrow: async () => {
52+
console.log('Regular action that will be stripped');
53+
return { type: 'regular' };
54+
},
55+
56+
// Inline async function
57+
inlineAsync: async function() {
58+
return { data: 'async regular' };
59+
},
60+
});
61+
62+
// Test 9: createServerActions (plural) with various patterns
63+
const serverActions = moduleAPI.createServerActions({
64+
// Server action with inline logic
65+
authenticate: async () => {
66+
const session = serverStates.userSession.getState();
67+
console.log('Authenticating user:', session.userId);
68+
return { authenticated: true, userId: session.userId };
69+
},
70+
71+
// Server action with nested logic
72+
authorize: async () => {
73+
const keys = serverStates.apiKeys.getState();
74+
console.log('Authorizing with keys');
75+
return { authorized: true, hasStripeKey: !!keys.stripe };
76+
},
77+
78+
// Server action accessing server state
79+
getSecrets: async () => {
80+
const config = singleServerState.getState();
81+
return { hasPassword: !!config.dbPassword };
82+
},
83+
});
84+
85+
// UI Component to verify behavior
86+
const EdgeCasesUI: React.FC = () => {
87+
return (
88+
<div style={{padding: '20px', fontFamily: 'monospace'}}>
89+
<h1>Server State Edge Cases Test</h1>
90+
<div style={{marginTop: '20px'}}>
91+
<h2>Regular Actions (backwards compat):</h2>
92+
<button onClick={() => regularAction1()}>Regular Action 1</button>
93+
<button onClick={() => regularActions.inlineArrow()}>Regular Actions.inlineArrow</button>
94+
<button onClick={() => regularActions.inlineAsync()}>Regular Actions.inlineAsync</button>
95+
</div>
96+
<div style={{marginTop: '20px'}}>
97+
<h2>Server Actions:</h2>
98+
<button onClick={() => serverAction1()}>Server Action 1 (inline)</button>
99+
<button onClick={() => serverAction2()}>Server Action 2 (factory)</button>
100+
<button onClick={() => serverAction3()}>Server Action 3 (variable)</button>
101+
<button onClick={() => serverActions.authenticate()}>Server Actions.authenticate</button>
102+
<button onClick={() => serverActions.authorize()}>Server Actions.authorize</button>
103+
<button onClick={() => serverActions.getSecrets()}>Server Actions.getSecrets</button>
104+
</div>
105+
<div style={{marginTop: '20px'}}>
106+
<h3>Expected Behavior:</h3>
107+
<ul>
108+
<li><strong>Browser Build:</strong> All server states removed, all action bodies empty</li>
109+
<li><strong>Server Build:</strong> Everything intact with full implementation</li>
110+
</ul>
111+
</div>
112+
</div>
113+
);
114+
};
115+
116+
moduleAPI.registerRoute('/', {}, () => <EdgeCasesUI />);
117+
});
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import React from 'react';
2+
import springboard from 'springboard';
3+
import {Module, registerModule} from 'springboard/module_registry/module_registry';
4+
springboard
5+
6+
type StateSyncTestState = {
7+
sharedCounter: number;
8+
serverSecretValue: string;
9+
lastUpdated: number;
10+
};
11+
12+
/**
13+
* Test module demonstrating the difference between server-only state and shared state.
14+
*
15+
* - sharedCounter: Syncs across all clients in real-time
16+
* - serverSecretValue: Only exists on server, never exposed to clients
17+
* - lastUpdated: Timestamp of last update (shared)
18+
*/
19+
springboard.registerModule('state_sync_test', {}, async (moduleAPI) => {
20+
const sharedState = await moduleAPI.statesAPI.createSharedState('counter', {
21+
value: 0,
22+
lastUpdated: Date.now(),
23+
});
24+
25+
const serverState = await moduleAPI.statesAPI.createServerState('secret', {
26+
apiKey: 'super-secret-key-12345',
27+
internalCounter: 0,
28+
});
29+
30+
// Actions to manipulate state
31+
const actions = moduleAPI.createActions({
32+
incrementShared: async () => {
33+
const current = sharedState.getState();
34+
sharedState.setState({
35+
value: current.value + 1,
36+
lastUpdated: Date.now(),
37+
});
38+
},
39+
40+
incrementServer: async () => {
41+
const current = serverState.getState();
42+
serverState.setState({
43+
...current,
44+
internalCounter: current.internalCounter + 1,
45+
});
46+
},
47+
48+
getServerValue: async () => {
49+
// This action runs on the server and returns the server-only value
50+
const current = serverState.getState();
51+
return {internalCounter: current.internalCounter};
52+
},
53+
});
54+
55+
// UI Component
56+
const StateTestUI: React.FC = () => {
57+
const shared = sharedState.useState();
58+
const [serverCount, setServerCount] = React.useState<number | null>(null);
59+
60+
const fetchServerCount = async () => {
61+
const result = await actions.getServerValue();
62+
setServerCount(result.internalCounter);
63+
};
64+
65+
React.useEffect(() => {
66+
fetchServerCount();
67+
}, []);
68+
69+
return (
70+
<div style={{padding: '20px', fontFamily: 'sans-serif'}}>
71+
<h1>State Synchronization Test</h1>
72+
73+
<div style={{
74+
border: '2px solid #4CAF50',
75+
padding: '15px',
76+
marginBottom: '20px',
77+
borderRadius: '8px',
78+
}}>
79+
<h2>✅ Shared State (Syncs to All Clients)</h2>
80+
<p><strong>Counter Value:</strong> <span id="shared-counter">{shared.value}</span></p>
81+
<p><small>Last Updated: {new Date(shared.lastUpdated).toLocaleTimeString()}</small></p>
82+
<button
83+
id="increment-shared-btn"
84+
onClick={() => actions.incrementShared()}
85+
style={{
86+
padding: '10px 20px',
87+
fontSize: '16px',
88+
backgroundColor: '#4CAF50',
89+
color: 'white',
90+
border: 'none',
91+
borderRadius: '4px',
92+
cursor: 'pointer',
93+
}}
94+
>
95+
Increment Shared Counter
96+
</button>
97+
</div>
98+
99+
<div style={{
100+
border: '2px solid #ff9800',
101+
padding: '15px',
102+
borderRadius: '8px',
103+
}}>
104+
<h2>🔒 Server-Only State (Never Syncs to Clients)</h2>
105+
<p><strong>Internal Counter:</strong> <span id="server-counter">{serverCount ?? 'Loading...'}</span></p>
106+
<p><small>Note: This value is fetched via RPC action, not synced automatically</small></p>
107+
<div style={{display: 'flex', gap: '10px'}}>
108+
<button
109+
id="increment-server-btn"
110+
onClick={async () => {
111+
await actions.incrementServer();
112+
await fetchServerCount();
113+
}}
114+
style={{
115+
padding: '10px 20px',
116+
fontSize: '16px',
117+
backgroundColor: '#ff9800',
118+
color: 'white',
119+
border: 'none',
120+
borderRadius: '4px',
121+
cursor: 'pointer',
122+
}}
123+
>
124+
Increment Server Counter
125+
</button>
126+
<button
127+
id="refresh-server-btn"
128+
onClick={fetchServerCount}
129+
style={{
130+
padding: '10px 20px',
131+
fontSize: '16px',
132+
backgroundColor: '#2196F3',
133+
color: 'white',
134+
border: 'none',
135+
borderRadius: '4px',
136+
cursor: 'pointer',
137+
}}
138+
>
139+
Refresh Server Value
140+
</button>
141+
</div>
142+
</div>
143+
144+
<div style={{
145+
marginTop: '20px',
146+
padding: '15px',
147+
backgroundColor: '#f5f5f5',
148+
borderRadius: '8px',
149+
}}>
150+
<h3>Testing Instructions</h3>
151+
<ol>
152+
<li>Open this page in two browser windows/tabs</li>
153+
<li>Click "Increment Shared Counter" in one window - both windows update instantly</li>
154+
<li>Click "Increment Server Counter" in one window - only updates when you click "Refresh"</li>
155+
<li>Server-only state is never automatically synchronized to clients</li>
156+
</ol>
157+
</div>
158+
</div>
159+
);
160+
};
161+
162+
moduleAPI.registerRoute('/', {}, () => <StateTestUI />);
163+
});

apps/small_apps/tic_tac_toe/tic_tac_toe.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,9 @@ const checkForWinner = (board: Board): Winner => {
5353
springboard.registerModule('TicTacToe', {}, async (moduleAPI) => {
5454
// TODO: springboard docs. if you need to wipe the initial state, you need to rename the state name
5555
// or "re-set" it right below for one run of the program
56-
const boardState = await moduleAPI.statesAPI.createPersistentState<Board>('board_v5', initialBoard);
57-
const winnerState = await moduleAPI.statesAPI.createPersistentState<Winner>('winner', null);
58-
const scoreState = await moduleAPI.statesAPI.createPersistentState<Score>('score', {X: 0, O: 0, stalemate: 0});
56+
const boardState = await moduleAPI.statesAPI.createSharedState<Board>('board_v5', initialBoard);
57+
const winnerState = await moduleAPI.statesAPI.createSharedState<Winner>('winner', null);
58+
const scoreState = await moduleAPI.statesAPI.createSharedState<Score>('score', {X: 0, O: 0, stalemate: 0});
5959

6060
const actions = moduleAPI.createActions({
6161
clickedCell: async (args: {row: number, column: number}) => {

apps/small_apps/tsconfig.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"extends": "../../tsconfig.json",
3+
"compilerOptions": {
4+
"baseUrl": ".",
5+
"lib": ["ES2019", "DOM", "DOM.Iterable"],
6+
"types": ["node"]
7+
},
8+
"include": [
9+
"**/*.ts",
10+
"**/*.tsx"
11+
],
12+
"exclude": [
13+
"node_modules"
14+
]
15+
}

packages/jamtools/core/modules/chord_families/chord_families_module.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ declare module 'springboard/module_registry/module_registry' {
106106
// });
107107

108108
springboard.registerModule('chord_families', {}, async (moduleAPI) => {
109-
const savedData = await moduleAPI.statesAPI.createPersistentState<ChordFamilyData[]>('all_chord_families', []);
109+
const savedData = await moduleAPI.statesAPI.createSharedState<ChordFamilyData[]>('all_chord_families', []);
110110

111111
const getChordFamilyHandler = (key: string): ChordFamilyHandler => {
112112
const data = savedData.getState()[0];

packages/jamtools/core/modules/macro_module/macro_handlers/inputs/midi_button_input_macro_handler.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ macroTypeRegistry.registerMacroType('midi_button_input', {}, async (macroAPI, co
3535
const editing = await macroAPI.statesAPI.createSharedState(getKeyForMacro('editing', fieldName), false);
3636
const waitingForConfiguration = await macroAPI.statesAPI.createSharedState(getKeyForMacro('waiting_for_configuration', fieldName), false);
3737
const capturedMidiEvent = await macroAPI.statesAPI.createSharedState<MidiEventFull | null>(getKeyForMacro('captured_midi_event', fieldName), null);
38-
const savedMidiEvents = await macroAPI.statesAPI.createPersistentState<MidiEventFull[]>(getKeyForMacro('saved_midi_event', fieldName), []);
38+
const savedMidiEvents = await macroAPI.statesAPI.createSharedState<MidiEventFull[]>(getKeyForMacro('saved_midi_event', fieldName), []);
3939
const states: InputMacroStateHolders = {
4040
editing,
4141
waiting: waitingForConfiguration,

packages/jamtools/core/modules/macro_module/macro_handlers/inputs/midi_control_change_input_macro_handler.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ macroTypeRegistry.registerMacroType('midi_control_change_input', {}, async (macr
2525
const editing = await macroAPI.statesAPI.createSharedState(getKeyForMacro('editing', fieldName), false);
2626
const waitingForConfiguration = await macroAPI.statesAPI.createSharedState(getKeyForMacro('waiting_for_configuration', fieldName), false);
2727
const capturedMidiEvent = await macroAPI.statesAPI.createSharedState<MidiEventFull | null>(getKeyForMacro('captured_midi_event', fieldName), null);
28-
const savedMidiEvents = await macroAPI.statesAPI.createPersistentState<MidiEventFull[]>(getKeyForMacro('saved_midi_event', fieldName), []);
28+
const savedMidiEvents = await macroAPI.statesAPI.createSharedState<MidiEventFull[]>(getKeyForMacro('saved_midi_event', fieldName), []);
2929
const states: InputMacroStateHolders = {
3030
editing,
3131
waiting: waitingForConfiguration,

0 commit comments

Comments
 (0)