Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ dist
site
typedoc_docs
kv.db
kv-2.db
.env
kv_data.json

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# State Management

Springboard provides three types of state, each with different persistence and sync behavior.
Springboard provides four types of state, each with different persistence and sync behavior.

## State Types Comparison

Expand All @@ -9,6 +9,7 @@ Springboard provides three types of state, each with different persistence and s
| SharedState | In-memory (server cache) | Real-time cross-device | Temporary shared data, UI sync |
| PersistentState | Database | Cross-device on load | User data, settings, saved state |
| UserAgentState | localStorage | None (device-local) | UI preferences, local settings |
| LocalSessionState | sessionStorage | None (tab-local) | Tab-specific temporary state |

## createSharedState

Expand Down Expand Up @@ -92,6 +93,39 @@ localPrefs.setStateImmer(draft => {
});
```

## createLocalSessionState

```typescript
const formData = await moduleAPI.statesAPI.createLocalSessionState('formData', {
step: 1,
values: {}
});
```

**Behavior:**
- Stored in sessionStorage (browser only)
- Never synced to server or other devices
- Tab/window-specific
- Cleared when tab/window is closed
- Use for: multi-step forms, temporary drafts, tab-specific UI state

**Example:**
```typescript
const wizardState = await moduleAPI.statesAPI.createLocalSessionState('wizard', {
currentStep: 1,
formData: {} as Record<string, any>,
visited: [] as number[]
});

// Only affects this browser tab
wizardState.setStateImmer(draft => {
draft.currentStep = 2;
draft.visited.push(1);
});

// State is automatically cleared when tab closes
```

## StateSupervisor API

All state types return a `StateSupervisor<T>`:
Expand Down Expand Up @@ -140,4 +174,11 @@ moduleAPI.onDestroy(() => subscription.unsubscribe());
**Use UserAgentState when:**
- Data is device-specific
- No need to sync across devices
- Examples: UI state, local preferences, cached data
- Should persist across sessions
- Examples: UI preferences, local settings, cached data

**Use LocalSessionState when:**
- Data is tab/window-specific
- Should NOT persist when tab closes
- No need to sync across devices or tabs
- Examples: multi-step form progress, temporary drafts, wizard state, tab-specific UI
12 changes: 12 additions & 0 deletions packages/springboard/src/core/engine/engine.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export class Springboard {

private remoteSharedStateService!: SharedStateService;
private localSharedStateService!: SharedStateService;
private sessionSharedStateService?: SharedStateService;

initialize = async () => {
const initStartTime = now();
Expand Down Expand Up @@ -97,6 +98,16 @@ export class Springboard {
});
await this.localSharedStateService.initialize();

if (this.coreDeps.storage.session) {
this.sessionSharedStateService = new SharedStateService({
rpc: this.coreDeps.rpc.local,
kv: this.coreDeps.storage.session,
log: this.coreDeps.log,
isMaestro: this.coreDeps.isMaestro,
});
await this.sessionSharedStateService.initialize();
}

this.moduleRegistry = new ModuleRegistry();

const registeredClassModuleCallbacks = (springboard.registerClassModule as unknown as {calls: CapturedRegisterClassModuleCalls[]}).calls || [];
Expand Down Expand Up @@ -182,6 +193,7 @@ export class Springboard {
services: {
remoteSharedStateService: this.remoteSharedStateService,
localSharedStateService: this.localSharedStateService,
sessionSharedStateService: this.sessionSharedStateService,
},
};
};
Expand Down
36 changes: 36 additions & 0 deletions packages/springboard/src/core/engine/module_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,4 +306,40 @@ export class StatesAPI {

return supervisor;
};

/**
* Create a piece of state to be saved in sessionStorage. This state persists only for the current browser tab/window session.
* The state is cleared when the tab/window is closed, making it useful for temporary, tab-specific state.
*/
public createLocalSessionState = async <State>(stateName: string, initialValue: State): Promise<StateSupervisor<State>> => {
const fullKey = `${this.prefix}|state.session|${stateName}`;

if (this.coreDeps.storage.session && this.modDeps.services.sessionSharedStateService) {
const cachedValue = this.modDeps.services.sessionSharedStateService.getCachedValue(fullKey) as State | undefined;
if (cachedValue !== undefined) {
initialValue = cachedValue;
} else {
const storedValue = await this.coreDeps.storage.session.get<State>(fullKey);
if (storedValue !== null && storedValue !== undefined) {
initialValue = storedValue;
}
}

const supervisor = new SharedStateSupervisor(fullKey, initialValue, this.modDeps.services.sessionSharedStateService);

const sub = supervisor.subjectForKVStorePublish.subscribe(async value => {
await this.coreDeps.storage.session!.set(fullKey, value);
});
this.onDestroy(sub.unsubscribe);

return supervisor;
}

// Fallback to UserAgentStateSupervisor if session storage not available
const supervisor = new UserAgentStateSupervisor(fullKey, initialValue, this.coreDeps.storage.userAgent);
this.onDestroy(() => {
// No-op cleanup since UserAgentStateSupervisor handles its own storage
});
return supervisor;
};
}
2 changes: 2 additions & 0 deletions packages/springboard/src/core/types/module_types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export type CoreDependencies = {
storage: {
remote: KVStore;
userAgent: KVStore;
session?: KVStore;
};
rpc: {
remote: Rpc;
Expand Down Expand Up @@ -60,5 +61,6 @@ export type ModuleDependencies = {
services: {
remoteSharedStateService: SharedStateService;
localSharedStateService : SharedStateService;
sessionSharedStateService?: SharedStateService;
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {MockRpcService} from '../../../core/test/mock_core_dependencies.js';
import React from 'react';

import {BrowserKVStoreService} from '../services/browser_kvstore_service.js';
import {BrowserSessionKVStoreService} from '../services/browser_session_kvstore_service.js';
import {startAndRenderBrowserApp} from './react_entrypoint.js';

(globalThis as {useHashRouter?: boolean}).useHashRouter = true;
Expand All @@ -11,6 +12,7 @@ setTimeout(() => {
const rpc = new MockRpcService();
const remoteKvStore = new BrowserKVStoreService(localStorage);
const userAgentKVStore = new BrowserKVStoreService(localStorage);
const sessionKVStore = new BrowserSessionKVStoreService(sessionStorage);

startAndRenderBrowserApp({
rpc: {
Expand All @@ -21,6 +23,7 @@ setTimeout(() => {
storage: {
userAgent: userAgentKVStore,
remote: remoteKvStore,
session: sessionKVStore,
},
});
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {BrowserJsonRpcClientAndServer} from '../services/browser_json_rpc.js';
import {BrowserKVStoreService} from '../services/browser_kvstore_service.js';
import {BrowserSessionKVStoreService} from '../services/browser_session_kvstore_service.js';
import {HttpKvStoreClient as HttpKVStoreService} from '../../../core/services/http_kv_store_client.js';
import {startAndRenderBrowserApp} from './react_entrypoint.js';

Expand All @@ -20,6 +21,7 @@ setTimeout(() => {
const rpc = new BrowserJsonRpcClientAndServer(`${WS_HOST}/ws`);
const remoteKvStore = new HttpKVStoreService(DATA_HOST);
const userAgentKVStore = new BrowserKVStoreService(localStorage);
const sessionKVStore = new BrowserSessionKVStoreService(sessionStorage);

startAndRenderBrowserApp({
rpc: {
Expand All @@ -29,6 +31,7 @@ setTimeout(() => {
storage: {
userAgent: userAgentKVStore,
remote: remoteKvStore,
session: sessionKVStore,
},
dev: {
reloadCss,
Expand Down
1 change: 1 addition & 0 deletions packages/springboard/src/platforms/browser/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
// Export browser services
export { BrowserJsonRpcClientAndServer } from './services/browser_json_rpc.js';
export { BrowserKVStoreService } from './services/browser_kvstore_service.js';
export { BrowserSessionKVStoreService } from './services/browser_session_kvstore_service.js';

// Export browser entrypoints
export { startAndRenderBrowserApp } from './entrypoints/react_entrypoint.js';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import {KVStore} from '../../../core/types/module_types.js';

export class BrowserSessionKVStoreService implements KVStore {
constructor(private ss: Window['sessionStorage']) {}

getAll = async (): Promise<Record<string, any> | null> => {
const allKeys = Object.keys(this.ss);

const entriesAsRecord: Record<string, any> = {};
for (const key of allKeys) {
const value = this.ss.getItem(key);
if (value) {
try {
entriesAsRecord[key] = JSON.parse(value);
} catch (e) {
// eslint-disable-line no-empty
}
}
}

return entriesAsRecord;
};

get = async <T>(key: string): Promise<T | null> => {
const s = this.ss.getItem(key);
if (!s) {
return null;
}

return JSON.parse(s) as T;
};

set = async <T>(key: string, value: T): Promise<void> => {
const s = JSON.stringify(value);
this.ss.setItem(key, s);
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {HttpKvStoreClient as HttpKVStoreService} from '../../../core/services/ht
import {Main} from '../../browser/entrypoints/main.js';
// import {Main} from './main.js';
import {BrowserKVStoreService} from '../../browser/services/browser_kvstore_service.js';
import {BrowserSessionKVStoreService} from '../../browser/services/browser_session_kvstore_service.js';
import {BrowserJsonRpcClientAndServer} from '../../browser/services/browser_json_rpc.js';
import {Springboard} from '../../../core/engine/engine.js';
import {ExtraModuleDependencies} from '../../../core/module_registry/module_registry.js';
Expand All @@ -35,6 +36,7 @@ export const startAndRenderBrowserApp = async (): Promise<Springboard> => {

// const kvStore = new BrowserKVStoreService(localStorage);
const userAgentKVStore = new BrowserKVStoreService(localStorage);
const sessionKVStore = new BrowserSessionKVStoreService(sessionStorage);

// const kvStore = mockDeps.storage.remote;
// const userAgentKVStore = mockDeps.storage.userAgent;
Expand All @@ -48,6 +50,7 @@ export const startAndRenderBrowserApp = async (): Promise<Springboard> => {
storage: {
remote: kvStore,
userAgent: userAgentKVStore,
session: sessionKVStore,
},
rpc: {
remote: rpc,
Expand Down
28 changes: 28 additions & 0 deletions packages/springboard/vite-plugin/src/plugins/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,34 @@ export function springboardDev(options: NormalizedOptions): Plugin {
logger.debug(`HMR update: ${file}`);
}

// If user code changed, re-initialize the engine after module reload
// The ModuleRunner will automatically re-import the module, but we need
// to call start() again to re-initialize the Springboard engine

// Check if the changed file is within the project root (excludes node_modules, etc.)
// and is not a generated file (excludes .springboard/, dist/, etc.)
const isUserCode = isNodePlatformActive &&
file.startsWith(options.root) &&
!file.includes(path.sep + 'node_modules' + path.sep) &&
!file.includes(path.sep + '.springboard' + path.sep) &&
!file.includes(path.sep + 'dist' + path.sep);

if (isUserCode) {
// Schedule start() to be called after the module reloads
// Use setImmediate to allow the module reload to complete first
setImmediate(async () => {
try {
if (nodeEntryModule && typeof nodeEntryModule.start === 'function') {
logger.info('[HMR] Re-initializing Springboard engine...');
await nodeEntryModule.start();
logger.info('[HMR] Engine re-initialized successfully');
}
} catch (err) {
logger.error(`[HMR] Failed to re-initialize engine: ${err}`);
}
});
}

// Let Vite handle HMR normally
return undefined;
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ export async function start() {
hooks: createWebSocketHooks(useWebSocketsForRpc),
});

// Use configured port (ignores process.env.PORT to avoid conflicts)
const port = __PORT__;
// Use PORT environment variable if defined, otherwise use configured port
const port = process.env.PORT ? parseInt(process.env.PORT, 10) : __PORT__;

// Start the HTTP server
server = serve({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { startAndRenderBrowserApp } from 'springboard/platforms/browser/entrypoi
import { BrowserJsonRpcClientAndServer } from 'springboard/platforms/browser/services/browser_json_rpc';
import { HttpKvStoreClient } from 'springboard/core/services/http_kv_store_client';
import { BrowserKVStoreService } from 'springboard/platforms/browser/services/browser_kvstore_service';
import { BrowserSessionKVStoreService } from 'springboard/platforms/browser/services/browser_session_kvstore_service';
import '__USER_ENTRY__';

// Determine protocol based on current page
Expand All @@ -16,9 +17,10 @@ const DATA_HOST = import.meta.env.VITE_DATA_HOST || `${httpProtocol}://${locatio
const rpc = new BrowserJsonRpcClientAndServer(`${WS_HOST}/ws`);
const remoteKvStore = new HttpKvStoreClient(DATA_HOST);
const userAgentKvStore = new BrowserKVStoreService(localStorage);
const sessionKvStore = new BrowserSessionKVStoreService(sessionStorage);

startAndRenderBrowserApp({
rpc: { remote: rpc, local: undefined },
storage: { userAgent: userAgentKvStore, remote: remoteKvStore },
storage: { userAgent: userAgentKvStore, remote: remoteKvStore, session: sessionKvStore },
dev: { reloadCss: false, reloadJs: false },
});
Loading