diff --git a/.gitignore b/.gitignore index c0dd68fd..ea878303 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ dist site typedoc_docs kv.db +kv-2.db .env kv_data.json diff --git a/packages/springboard/cli/src/docs/content/springboard-state-management.md b/packages/springboard/cli/src/docs/content/springboard-state-management.md index 0f1200cd..bc354c3e 100644 --- a/packages/springboard/cli/src/docs/content/springboard-state-management.md +++ b/packages/springboard/cli/src/docs/content/springboard-state-management.md @@ -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 @@ -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 @@ -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, + 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`: @@ -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 diff --git a/packages/springboard/src/core/engine/engine.tsx b/packages/springboard/src/core/engine/engine.tsx index 7755f46d..1f54385d 100644 --- a/packages/springboard/src/core/engine/engine.tsx +++ b/packages/springboard/src/core/engine/engine.tsx @@ -61,6 +61,7 @@ export class Springboard { private remoteSharedStateService!: SharedStateService; private localSharedStateService!: SharedStateService; + private sessionSharedStateService?: SharedStateService; initialize = async () => { const initStartTime = now(); @@ -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 || []; @@ -182,6 +193,7 @@ export class Springboard { services: { remoteSharedStateService: this.remoteSharedStateService, localSharedStateService: this.localSharedStateService, + sessionSharedStateService: this.sessionSharedStateService, }, }; }; diff --git a/packages/springboard/src/core/engine/module_api.ts b/packages/springboard/src/core/engine/module_api.ts index 92f75902..bbeb8877 100644 --- a/packages/springboard/src/core/engine/module_api.ts +++ b/packages/springboard/src/core/engine/module_api.ts @@ -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 (stateName: string, initialValue: State): Promise> => { + 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(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; + }; } diff --git a/packages/springboard/src/core/types/module_types.ts b/packages/springboard/src/core/types/module_types.ts index 1c131678..3b6f83b3 100644 --- a/packages/springboard/src/core/types/module_types.ts +++ b/packages/springboard/src/core/types/module_types.ts @@ -14,6 +14,7 @@ export type CoreDependencies = { storage: { remote: KVStore; userAgent: KVStore; + session?: KVStore; }; rpc: { remote: Rpc; @@ -60,5 +61,6 @@ export type ModuleDependencies = { services: { remoteSharedStateService: SharedStateService; localSharedStateService : SharedStateService; + sessionSharedStateService?: SharedStateService; }; } diff --git a/packages/springboard/src/platforms/browser/entrypoints/offline_entrypoint.ts b/packages/springboard/src/platforms/browser/entrypoints/offline_entrypoint.ts index c2f3433b..b698215c 100644 --- a/packages/springboard/src/platforms/browser/entrypoints/offline_entrypoint.ts +++ b/packages/springboard/src/platforms/browser/entrypoints/offline_entrypoint.ts @@ -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; @@ -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: { @@ -21,6 +23,7 @@ setTimeout(() => { storage: { userAgent: userAgentKVStore, remote: remoteKvStore, + session: sessionKVStore, }, }); }); diff --git a/packages/springboard/src/platforms/browser/entrypoints/online_entrypoint.ts b/packages/springboard/src/platforms/browser/entrypoints/online_entrypoint.ts index 8da8cee7..81eebe99 100644 --- a/packages/springboard/src/platforms/browser/entrypoints/online_entrypoint.ts +++ b/packages/springboard/src/platforms/browser/entrypoints/online_entrypoint.ts @@ -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'; @@ -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: { @@ -29,6 +31,7 @@ setTimeout(() => { storage: { userAgent: userAgentKVStore, remote: remoteKvStore, + session: sessionKVStore, }, dev: { reloadCss, diff --git a/packages/springboard/src/platforms/browser/index.ts b/packages/springboard/src/platforms/browser/index.ts index 40ab7fd9..f02100bf 100644 --- a/packages/springboard/src/platforms/browser/index.ts +++ b/packages/springboard/src/platforms/browser/index.ts @@ -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'; diff --git a/packages/springboard/src/platforms/browser/services/browser_session_kvstore_service.ts b/packages/springboard/src/platforms/browser/services/browser_session_kvstore_service.ts new file mode 100644 index 00000000..043f9c9f --- /dev/null +++ b/packages/springboard/src/platforms/browser/services/browser_session_kvstore_service.ts @@ -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 | null> => { + const allKeys = Object.keys(this.ss); + + const entriesAsRecord: Record = {}; + 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 (key: string): Promise => { + const s = this.ss.getItem(key); + if (!s) { + return null; + } + + return JSON.parse(s) as T; + }; + + set = async (key: string, value: T): Promise => { + const s = JSON.stringify(value); + this.ss.setItem(key, s); + }; +} diff --git a/packages/springboard/src/platforms/tauri/entrypoints/platform_tauri_browser.tsx b/packages/springboard/src/platforms/tauri/entrypoints/platform_tauri_browser.tsx index 22aac1c2..6d1d7fdf 100644 --- a/packages/springboard/src/platforms/tauri/entrypoints/platform_tauri_browser.tsx +++ b/packages/springboard/src/platforms/tauri/entrypoints/platform_tauri_browser.tsx @@ -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'; @@ -35,6 +36,7 @@ export const startAndRenderBrowserApp = async (): Promise => { // 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; @@ -48,6 +50,7 @@ export const startAndRenderBrowserApp = async (): Promise => { storage: { remote: kvStore, userAgent: userAgentKVStore, + session: sessionKVStore, }, rpc: { remote: rpc, diff --git a/packages/springboard/vite-plugin/src/plugins/dev.ts b/packages/springboard/vite-plugin/src/plugins/dev.ts index 9ae312d7..5a32718a 100644 --- a/packages/springboard/vite-plugin/src/plugins/dev.ts +++ b/packages/springboard/vite-plugin/src/plugins/dev.ts @@ -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; }, diff --git a/packages/springboard/vite-plugin/src/templates/node-entry.template.ts b/packages/springboard/vite-plugin/src/templates/node-entry.template.ts index 14a836f0..f29507bb 100644 --- a/packages/springboard/vite-plugin/src/templates/node-entry.template.ts +++ b/packages/springboard/vite-plugin/src/templates/node-entry.template.ts @@ -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({ diff --git a/packages/springboard/vite-plugin/src/templates/web-entry.template.ts b/packages/springboard/vite-plugin/src/templates/web-entry.template.ts index 8b2afb35..514d9740 100644 --- a/packages/springboard/vite-plugin/src/templates/web-entry.template.ts +++ b/packages/springboard/vite-plugin/src/templates/web-entry.template.ts @@ -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 @@ -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 }, });