diff --git a/.springboard/node-dev-entry.ts b/.springboard/node-dev-entry.ts
new file mode 100644
index 00000000..7533396d
--- /dev/null
+++ b/.springboard/node-dev-entry.ts
@@ -0,0 +1,88 @@
+import process from 'node:process';
+
+import crosswsNode from 'crossws/adapters/node';
+
+import { initApp } from 'springboard/server/hono_app';
+import { makeWebsocketServerCoreDependenciesWithSqlite } from 'springboard/platforms/node/services/ws_server_core_dependencies';
+import { LocalJsonNodeKVStoreService } from 'springboard/platforms/node/services/node_kvstore_service';
+import { CoreDependencies, Springboard } from 'springboard/core';
+import {
+ springboard,
+ clearRegisteredModules,
+ clearRegisteredClassModules,
+ clearRegisteredSplashScreen,
+} from 'springboard/core/engine/register';
+import { resetServerRegistry } from 'springboard/server/register';
+
+export type DevServerHandle = {
+ fetch: (request: Request) => Promise;
+ ws: ReturnType;
+ dispose: () => Promise;
+};
+
+springboard.reset();
+clearRegisteredModules();
+clearRegisteredClassModules();
+clearRegisteredSplashScreen();
+resetServerRegistry();
+
+await import('../src/server-entry.ts');
+
+export async function createDevServer(): Promise {
+ const nodeKvDeps = await makeWebsocketServerCoreDependenciesWithSqlite();
+ const useWebSocketsForRpc = import.meta.env.VITE_USE_WEBSOCKETS_FOR_RPC === 'true';
+
+ let wsNode: ReturnType;
+
+ const { app, serverAppDependencies, injectResources, createWebSocketHooks } = initApp({
+ broadcastMessage: (message) => {
+ return wsNode.publish('event', message);
+ },
+ remoteKV: nodeKvDeps.kvStoreFromKysely,
+ userAgentKV: new LocalJsonNodeKVStoreService('userAgent'),
+ enableStaticRoutes: false,
+ });
+
+ app.notFound((c) => {
+ c.header('x-springboard-fallback', '1');
+ return c.text('', 404);
+ });
+
+ wsNode = crosswsNode({
+ hooks: createWebSocketHooks(useWebSocketsForRpc),
+ });
+
+ const coreDeps: CoreDependencies = {
+ log: console.log,
+ showError: console.error,
+ storage: serverAppDependencies.storage,
+ isMaestro: () => true,
+ rpc: serverAppDependencies.rpc,
+ };
+
+ Object.assign(coreDeps, serverAppDependencies);
+
+ const engine = new Springboard(coreDeps, {});
+
+ injectResources({
+ engine,
+ serveStaticFile: async (c, _fileName, headers) => {
+ Object.entries(headers).forEach(([key, value]) => {
+ c.header(key, value);
+ });
+ c.status(404);
+ return c.text('Not found');
+ },
+ getEnvValue: (name) => process.env[name],
+ });
+
+ await engine.initialize();
+
+ return {
+ fetch: app.fetch,
+ ws: wsNode,
+ dispose: async () => {
+ wsNode.closeAll();
+ },
+ };
+}
diff --git a/.springboard/node-entry.ts b/.springboard/node-entry.ts
new file mode 100644
index 00000000..f8f934bd
--- /dev/null
+++ b/.springboard/node-entry.ts
@@ -0,0 +1,163 @@
+import process from 'node:process';
+import path from 'node:path';
+
+import { serve } from '@hono/node-server';
+import crosswsNode from 'crossws/adapters/node';
+import type { Server } from 'node:http';
+
+import { initApp } from 'springboard/server/hono_app';
+import { makeWebsocketServerCoreDependenciesWithSqlite } from 'springboard/platforms/node/services/ws_server_core_dependencies';
+import { LocalJsonNodeKVStoreService } from 'springboard/platforms/node/services/node_kvstore_service';
+import { CoreDependencies, Springboard } from 'springboard/core';
+import '../src/server-entry.ts';
+
+/**
+ * Node.js server entrypoint with HMR support
+ *
+ * This file is generated by the Springboard Vite plugin and serves as the
+ * entry point for the Node.js dev server. It:
+ *
+ * 1. Imports the user's application entry (which registers modules)
+ * 2. Exports start/stop functions for lifecycle management
+ * 3. Supports HMR via import.meta.hot.dispose()
+ */
+
+let server: Server | null = null;
+let engine: Springboard | null = null;
+
+/**
+ * Start the node server
+ */
+export async function start() {
+ // If server is already running, stop it first
+ if (server) {
+ await stop();
+ }
+
+ try {
+ const webappFolder = process.env.WEBAPP_FOLDER || './dist';
+ const webappDistFolder = webappFolder;
+
+ const nodeKvDeps = await makeWebsocketServerCoreDependenciesWithSqlite();
+ const useWebSocketsForRpc = import.meta.env.VITE_USE_WEBSOCKETS_FOR_RPC === 'true';
+
+ let wsNode: ReturnType;
+
+ const { app, serverAppDependencies, injectResources, createWebSocketHooks } = initApp({
+ broadcastMessage: (message) => {
+ return wsNode.publish('event', message);
+ },
+ remoteKV: nodeKvDeps.kvStoreFromKysely,
+ userAgentKV: new LocalJsonNodeKVStoreService('userAgent'),
+ });
+
+ wsNode = crosswsNode({
+ hooks: createWebSocketHooks(useWebSocketsForRpc),
+ });
+
+ // Use configured port (ignores process.env.PORT to avoid conflicts)
+ const port = 1337;
+
+ // Start the HTTP server
+ server = serve({
+ fetch: app.fetch,
+ port,
+ }, (info) => {
+ console.log(`Server listening on http://localhost:${info.port}`);
+ });
+
+ server.on('upgrade', (request, socket, head) => {
+ const url = new URL(request.url || '', `http://${request.headers.host}`);
+ if (url.pathname === '/ws') {
+ wsNode.handleUpgrade(request, socket, head);
+ } else {
+ socket.end('HTTP/1.1 404 Not Found\r\n\r\n');
+ }
+ });
+
+ const coreDeps: CoreDependencies = {
+ log: console.log,
+ showError: console.error,
+ storage: serverAppDependencies.storage,
+ isMaestro: () => true,
+ rpc: serverAppDependencies.rpc,
+ };
+
+ Object.assign(coreDeps, serverAppDependencies);
+
+ const extraDeps = {}; // TODO: remove this extraDeps thing from the framework
+
+ engine = new Springboard(coreDeps, extraDeps);
+
+ injectResources({
+ engine,
+ serveStaticFile: async (c, fileName, headers) => {
+ try {
+ const fullPath = `${webappDistFolder}/${fileName}`;
+ const fs = await import('node:fs');
+ const data = await fs.promises.readFile(fullPath, 'utf-8');
+ c.status(200);
+
+ if (headers) {
+ Object.entries(headers).forEach(([key, value]) => {
+ c.header(key, value);
+ });
+ }
+
+ return c.body(data);
+ } catch (error) {
+ console.error('Error serving file:', error);
+ c.status(404);
+ return c.text('404 Not found');
+ }
+ },
+ getEnvValue: name => process.env[name],
+ });
+
+ await engine.initialize();
+ console.log('Node application started successfully');
+ } catch (error) {
+ console.error('Failed to start node server:', error);
+ throw error;
+ }
+}
+
+/**
+ * Stop the node server
+ */
+export async function stop() {
+ if (!server) {
+ return;
+ }
+
+ return new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ reject(new Error('Server close timeout'));
+ }, 5000);
+
+ server!.close((err) => {
+ clearTimeout(timeout);
+ if (err) {
+ reject(err);
+ } else {
+ console.log('Server stopped successfully');
+ server = null;
+ engine = null; // TODO: add explicit shutdown once the engine exposes it
+ resolve();
+ }
+ });
+ });
+}
+
+// HMR support: clean up before module reload
+if (import.meta.hot) {
+ import.meta.hot.dispose(async () => {
+ console.log('[HMR] Stopping server before reload...');
+ await stop();
+ });
+}
+
+// Auto-start in production builds (not in dev mode)
+if (!import.meta.env.DEV) {
+ start().catch(console.error);
+}
diff --git a/apps/vite-test/package.json b/apps/vite-test/package.json
index bd5f296f..1aedc0d4 100644
--- a/apps/vite-test/package.json
+++ b/apps/vite-test/package.json
@@ -9,10 +9,12 @@
"build:esbuild:watch": "tsx esbuild.ts --watch",
"build": "npm run build:web && npm run build:node",
"build-sb-and-app": "cd ../../packages/springboard && npm run build && cd vite-plugin && npm run build && cd ../../../apps/vite-test && npm run build",
+ "build-sb-and-dev": "cd ../../packages/springboard && npm run build && cd vite-plugin && npm run build && cd ../../../apps/vite-test && npm run dev",
"build:web": "SPRINGBOARD_PLATFORM=web vite build",
"build:node": "SPRINGBOARD_PLATFORM=node vite build --outDir dist/node",
- "dev": "vite",
- "check-types": "tsc --noEmit"
+ "dev": "vite --host 0.0.0.0",
+ "check-types": "tsc --noEmit",
+ "start": "node dist/node/node-entry.mjs"
},
"engines": {
"node": ">=20.0.0"
diff --git a/apps/vite-test/src/tic_tac_toe.tsx b/apps/vite-test/src/tic_tac_toe.tsx
index ba58aa0f..7624f412 100644
--- a/apps/vite-test/src/tic_tac_toe.tsx
+++ b/apps/vite-test/src/tic_tac_toe.tsx
@@ -4,6 +4,13 @@ import springboard from 'springboard';
// @platform "node"
console.log('only in node');
+
+import {serverRegistry} from 'springboard/server/register'
+serverRegistry.registerServerModule(api => {
+ api.hono.get('/yeah', async (req) => {
+ return new Response('Oh yeah');
+ });
+});
// @platform end
// @platform "browser"
diff --git a/apps/vite-test/vite.config.ts b/apps/vite-test/vite.config.ts
index 241d0c65..f9a95b47 100644
--- a/apps/vite-test/vite.config.ts
+++ b/apps/vite-test/vite.config.ts
@@ -6,7 +6,7 @@ export default defineConfig({
springboard({
entry: './src/tic_tac_toe.tsx',
// platforms: ['browser', 'node'],
- nodeServerPort: 3001,
+ nodeServerPort: (process.env.PORT && parseInt(process.env.PORT)) || 3001,
})
],
define: {
diff --git a/package.json b/package.json
index aa49a61f..f0c4b6b0 100644
--- a/package.json
+++ b/package.json
@@ -18,7 +18,7 @@
"debug-node": "npm run debug --prefix apps/jamtools/node",
"test": "turbo run test",
"test:watch": "turbo run test:watch",
- "pretest:e2e": "pnpm --filter @springboard/vite-plugin build",
+ "pretest:e2e": "pnpm --filter springboard build && pnpm --filter @springboard/vite-plugin build",
"test:e2e": "vitest run tests/e2e",
"test:e2e:watch": "vitest watch tests/e2e",
"pretest:integration": "pnpm --filter @springboard/vite-plugin build",
diff --git a/packages/jamtools/core/package.json b/packages/jamtools/core/package.json
index 9df9b38f..361aebf9 100644
--- a/packages/jamtools/core/package.json
+++ b/packages/jamtools/core/package.json
@@ -50,6 +50,22 @@
"types": "./dist/modules/midi_files/midi_file_parser/midi_file_parser.d.ts",
"import": "./dist/modules/midi_files/midi_file_parser/midi_file_parser.js"
},
+ "./modules/midi_files/midi_files_module": {
+ "types": "./dist/modules/midi_files/midi_files_module.d.ts",
+ "import": "./dist/modules/midi_files/midi_files_module.js"
+ },
+ "./modules/io/io_dependencies": {
+ "types": "./dist/modules/io/io_dependencies_types.d.ts",
+ "node": {
+ "import": "./dist/modules/io/io_dependencies.node.js"
+ },
+ "browser": {
+ "import": "./dist/modules/io/io_dependencies.browser.js"
+ },
+ "default": {
+ "import": "./dist/modules/io/io_dependencies.js"
+ }
+ },
"./modules/macro_module/registered_macro_types": {
"types": "./dist/modules/macro_module/registered_macro_types.d.ts",
"import": "./dist/modules/macro_module/registered_macro_types.js"
diff --git a/packages/jamtools/core/src/modules/io/io_dependencies.browser.ts b/packages/jamtools/core/src/modules/io/io_dependencies.browser.ts
new file mode 100644
index 00000000..193dd2fa
--- /dev/null
+++ b/packages/jamtools/core/src/modules/io/io_dependencies.browser.ts
@@ -0,0 +1,12 @@
+import {BrowserQwertyService} from '@jamtools/core/services/browser/browser_qwerty_service';
+import {BrowserMidiService} from '@jamtools/core/services/browser/browser_midi_service';
+import type {IoDeps} from './io_dependencies_types';
+
+export const createIoDependencies = async (): Promise => {
+ const qwerty = new BrowserQwertyService(document);
+ const midi = new BrowserMidiService();
+ return {
+ qwerty,
+ midi,
+ };
+};
diff --git a/packages/jamtools/core/src/modules/io/io_dependencies.node.ts b/packages/jamtools/core/src/modules/io/io_dependencies.node.ts
new file mode 100644
index 00000000..d1850626
--- /dev/null
+++ b/packages/jamtools/core/src/modules/io/io_dependencies.node.ts
@@ -0,0 +1,21 @@
+import {NodeQwertyService} from '@jamtools/core/services/node/node_qwerty_service';
+import {NodeMidiService} from '@jamtools/core/services/node/node_midi_service';
+import {MockMidiService} from '@jamtools/core/test/services/mock_midi_service';
+import {MockQwertyService} from '@jamtools/core/test/services/mock_qwerty_service';
+import type {IoDeps} from './io_dependencies_types';
+
+export const createIoDependencies = async (): Promise => {
+ if (process.env.DISABLE_IO === 'true') {
+ return {
+ qwerty: new MockQwertyService(),
+ midi: new MockMidiService(),
+ };
+ }
+
+ const qwerty = new NodeQwertyService();
+ const midi = new NodeMidiService();
+ return {
+ qwerty,
+ midi,
+ };
+};
diff --git a/packages/jamtools/core/src/modules/io/io_dependencies.ts b/packages/jamtools/core/src/modules/io/io_dependencies.ts
new file mode 100644
index 00000000..647075b3
--- /dev/null
+++ b/packages/jamtools/core/src/modules/io/io_dependencies.ts
@@ -0,0 +1,11 @@
+import {MockMidiService} from '@jamtools/core/test/services/mock_midi_service';
+import {MockQwertyService} from '@jamtools/core/test/services/mock_qwerty_service';
+import type {IoDeps} from './io_dependencies_types';
+
+// Default implementation for testing
+export const createIoDependencies = async (): Promise => {
+ return {
+ qwerty: new MockQwertyService(),
+ midi: new MockMidiService(),
+ };
+};
diff --git a/packages/jamtools/core/src/modules/io/io_dependencies_types.ts b/packages/jamtools/core/src/modules/io/io_dependencies_types.ts
new file mode 100644
index 00000000..5506a98d
--- /dev/null
+++ b/packages/jamtools/core/src/modules/io/io_dependencies_types.ts
@@ -0,0 +1,8 @@
+import {MidiService, QwertyService} from '@jamtools/core/types/io_types';
+
+export type IoDeps = {
+ midi: MidiService;
+ qwerty: QwertyService;
+}
+
+export type CreateIoDependencies = () => Promise;
diff --git a/packages/jamtools/core/src/modules/io/io_module.tsx b/packages/jamtools/core/src/modules/io/io_module.tsx
index 4474310b..b79d468b 100644
--- a/packages/jamtools/core/src/modules/io/io_module.tsx
+++ b/packages/jamtools/core/src/modules/io/io_module.tsx
@@ -7,60 +7,18 @@ import springboard from 'springboard';
import {StateSupervisor} from 'springboard/services/states/shared_state_service';
import {ModuleAPI} from 'springboard/engine/module_api';
import {MidiEvent} from '@jamtools/core/modules/macro_module/macro_module_types';
-import {MockMidiService} from '@jamtools/core/test/services/mock_midi_service';
-import {MockQwertyService} from '@jamtools/core/test/services/mock_qwerty_service';
-import {MidiService, QwertyService} from '@jamtools/core/types/io_types';
+import type {IoDeps, CreateIoDependencies} from './io_dependencies_types';
+import {createIoDependencies as defaultCreateIoDependencies} from './io_dependencies';
-type IoDeps = {
- midi: MidiService;
- qwerty: QwertyService;
-}
-
-let createIoDependencies = async (): Promise => {
- return {
- qwerty: new MockQwertyService(),
- midi: new MockMidiService(),
- };
-};
-
-// @platform "browser"
-createIoDependencies = async () => {
- const {BrowserQwertyService} = await import('@jamtools/core/services/browser/browser_qwerty_service');
- const {BrowserMidiService} = await import('@jamtools/core/services/browser/browser_midi_service');
-
- const qwerty = new BrowserQwertyService(document);
- const midi = new BrowserMidiService();
- return {
- qwerty,
- midi,
- };
-};
-// @platform end
-
-// @platform "node"
-createIoDependencies = async () => {
- if (process.env.DISABLE_IO === 'true') {
- return {
- qwerty: new MockQwertyService(),
- midi: new MockMidiService(),
- };
- }
-
- const {NodeQwertyService} = await import('@jamtools/core/services/node/node_qwerty_service');
- const {NodeMidiService} = await import('@jamtools/core/services/node/node_midi_service');
-
- const qwerty = new NodeQwertyService();
- const midi = new NodeMidiService();
- return {
- qwerty,
- midi,
- };
+// Wrapper object to allow mutation for testing
+const ioDepsConfig = {
+ createIoDependencies: defaultCreateIoDependencies
};
-// @platform end
-export const setIoDependencyCreator = (func: typeof createIoDependencies) => {
- createIoDependencies = func;
+export const setIoDependencyCreator = (func: CreateIoDependencies) => {
+ // This is used for testing to override the platform-specific implementation
+ ioDepsConfig.createIoDependencies = func;
};
type IoState = {
@@ -120,7 +78,7 @@ export class IoModule implements Module {
};
initialize = async (moduleAPI: ModuleAPI) => {
- this.ioDeps = await createIoDependencies();
+ this.ioDeps = await ioDepsConfig.createIoDependencies();
this.qwertyInputSubject = this.ioDeps.qwerty.onInputEvent;
this.midiInputSubject = this.ioDeps.midi.onInputEvent;
diff --git a/packages/jamtools/core/src/modules/midi_files/midi_file_parser/midi_file_parser.ts b/packages/jamtools/core/src/modules/midi_files/midi_file_parser/midi_file_parser.ts
index 18af9442..5a9d6c94 100644
--- a/packages/jamtools/core/src/modules/midi_files/midi_file_parser/midi_file_parser.ts
+++ b/packages/jamtools/core/src/modules/midi_files/midi_file_parser/midi_file_parser.ts
@@ -1,6 +1,8 @@
import midi from 'midi-file';
-import {Midi} from '@tonejs/midi';
+import tonejs from '@tonejs/midi';
+import type {Midi} from '@tonejs/midi';
+const {Midi: MidiClass} = tonejs;
type SustainedNote = {
midiNumber: number;
@@ -19,7 +21,7 @@ export type ParsedMidiFile = {
export class MidiFileParser {
parseWithTonejsMidiBuffer = (input: Buffer) => {
- const parsed = new Midi(input);
+ const parsed = new MidiClass(input);
return this.parseWithTonejsMidiData(parsed);
};
diff --git a/packages/springboard/cli/src/docs_command.ts b/packages/springboard/cli/src/docs_command.ts
index 91290bbf..f7397bc0 100644
--- a/packages/springboard/cli/src/docs_command.ts
+++ b/packages/springboard/cli/src/docs_command.ts
@@ -86,77 +86,187 @@ Workflow:
const context = `# Springboard Development Context
-You are working on a Springboard application. Springboard is a full-stack JavaScript framework built on React, Hono, JSON-RPC, and WebSockets for building real-time, multi-device applications.
+Springboard apps are isomorphic React applications with server-capable actions, Hono-backed HTTP/WebSocket routes, JSON-RPC, and synced state supervisors. Treat code as shared between browser and node unless a platform guard says otherwise.
-## Available Documentation Sections
+After changing application code, run the project's type check, usually:
-${sectionsList}
+\`\`\`bash
+npm run check-types
+\`\`\`
-## Key Concepts
+## Practical Module Pattern
-### Module Structure
-\`\`\`typescript
-import springboard from 'springboard';
+\`\`\`tsx
+import springboard, {generateId} from 'springboard';
+import type {StateSupervisor} from 'springboard/services/states/shared_state_service';
+
+type ItemsState = {
+ version: 1;
+ items: Array<{id: string; name: string}>;
+};
+
+type LocalUiState = {
+ selectedId: string | null;
+};
-springboard.registerModule('ModuleName', {}, async (moduleAPI) => {
- // Create state (pick the right type!)
- const state = await moduleAPI.statesAPI.createSharedState('name', initialValue);
+springboard.registerModule('ItemsModule', {}, async (moduleAPI) => {
+ const shared = await moduleAPI.statesAPI.createSharedState('items', {
+ version: 1,
+ items: [],
+ });
+
+ const persistent = await moduleAPI.createStates({
+ settings: {
+ version: 1,
+ sortBy: 'name' as 'name' | 'createdAt',
+ },
+ });
+
+ const localUi = await moduleAPI.statesAPI.createUserAgentState('ui', {
+ selectedId: null,
+ });
- // Create actions (automatically RPC-enabled)
const actions = moduleAPI.createActions({
- actionName: async (args) => { /* ... */ }
+ addItem: async (args: {name: string}) => {
+ const item = {id: generateId(), name: args.name};
+
+ shared.setStateImmer(state => {
+ state.items.push(item);
+ });
+
+ return {data: item};
+ },
+
+ selectItem: async (args: {id: string | null}) => {
+ localUi.setState({selectedId: args.id});
+ return null;
+ },
});
- // Register routes
- moduleAPI.registerRoute('/', {}, MyComponent);
+ moduleAPI.registerRoute('/', {}, () => {
+ const liveShared = shared.useState();
+ const liveUi = localUi.useState();
+
+ return (
+
+ {liveShared.items.map(item => (
+
+ ))}
+
+ );
+ });
- // Cleanup
- moduleAPI.onDestroy(() => { /* cleanup */ });
+ moduleAPI.onDestroy(() => {
+ // Unsubscribe timers, subjects, DOM listeners, or external resources here.
+ });
- // Return public API
- return { state, actions };
+ return {shared, persistent, localUi, actions};
});
+
+declare module 'springboard/module_registry/module_registry' {
+ interface AllModules {
+ ItemsModule: {
+ shared: StateSupervisor;
+ persistent: {
+ settings: StateSupervisor<{
+ version: 1;
+ sortBy: 'name' | 'createdAt';
+ }>;
+ };
+ localUi: StateSupervisor;
+ actions: {
+ addItem: (args: {name: string}) => Promise<{data: {id: string; name: string}}>;
+ selectItem: (args: {id: string | null}) => Promise;
+ };
+ };
+ }
+}
\`\`\`
-### State Types
-- **createSharedState**: Real-time sync across devices (ephemeral)
-- **createPersistentState**: Database-backed, survives restarts
-- **createUserAgentState**: Local only (localStorage)
+## Accessing Modules From Components
-### StateSupervisor Methods
-\`\`\`typescript
-state.getState() // Get current value
-state.setState(newValue) // Immutable update
-state.setStateImmer(draft => {}) // Mutable update with Immer
-state.useState() // React hook
+\`\`\`tsx
+import {useModule} from 'springboard/engine/engine';
+
+export const ItemCount = () => {
+ const itemsModule = useModule('ItemsModule');
+ const state = itemsModule.shared.useState();
+
+ return {state.items.length};
+};
\`\`\`
-## Workflow
+Use \`moduleAPI.getModule('OtherModule')\` inside module setup, actions, and route callbacks. Use \`useModule('OtherModule')\` inside React components. If a module may be absent in a platform build, model that in \`AllModules\` and use optional chaining.
-1. **Use this context** + your React/TypeScript knowledge to write code
-2. **Fetch specific docs** with \`sb docs get \` only when needed
-3. **See examples** with \`sb docs examples list\` and \`sb docs examples show \`
+## State Choices
-## Available Examples
+- \`statesAPI.createSharedState\`: ephemeral synced state for live collaboration and current runtime state.
+- \`statesAPI.createPersistentState\`: synced state backed by remote storage.
+- \`moduleAPI.createStates\`: shorthand for multiple persistent states.
+- \`statesAPI.createUserAgentState\`: localStorage-backed state for one browser/device.
+- \`statesAPI.createLocalSessionState\`: sessionStorage-backed state for one browser tab/session.
-${examples.map(e => `- ${e.name}: ${e.description}`).join('\n')}
+State supervisors expose:
-## Common Mistakes to Avoid
+\`\`\`typescript
+state.getState();
+state.setState(nextValue);
+state.setState(prev => nextValue);
+state.setStateImmer(draft => {
+ // mutate draft
+});
+state.useState();
+\`\`\`
-1. **Don't call getModule at module level** - call inside routes/actions
-2. **Use optional chaining** for module access: \`maybeModule?.actions?.doSomething()\`
-3. **Don't mutate state directly** - use setState or setStateImmer
-4. **Clean up subscriptions** in onDestroy
-5. **Don't store computed values** in state - use useMemo
+## Actions And Platform Code
-## Getting Specific Documentation
+\`moduleAPI.createActions\` creates functions that can be called locally or over RPC. In server-driven apps, action bodies normally run on node, but the source file is still parsed for browser builds. Guard node-only imports and code:
-If you need detailed documentation on a topic, run:
-\`\`\`bash
-sb docs get springboard/module-api
-sb docs get springboard/state-management
-sb docs get springboard/patterns
+\`\`\`tsx
+const actions = moduleAPI.createActions({
+ readConfig: async () => {
+ // @platform "node"
+ const fs = await import('node:fs/promises');
+ const text = await fs.readFile('config.json', 'utf-8');
+ return {text};
+ // @platform end
+
+ return {text: ''};
+ },
+});
+
+// @platform "node"
+import './modules/ServerOnlyModule';
+// @platform end
+
+// @platform "browser"
+window.addEventListener('load', () => {
+ // browser-only setup
+});
+// @platform end
\`\`\`
+
+## Working Rules
+
+- Prefer Springboard state supervisors over ad hoc global state.
+- Do not mutate state returned by \`getState()\` or \`useState()\`; use \`setState\` or \`setStateImmer\`.
+- Keep derived values out of state; compute them in render or memoized selectors.
+- Register cleanup with \`moduleAPI.onDestroy\` for subscriptions, timers, and listeners.
+- Fetch focused docs only when needed: \`sb docs get springboard/module-api springboard/state-management\`.
+
+## Available Documentation Sections
+
+${sectionsList}
+
+## Available Examples
+
+${examples.map(e => `- ${e.name}: ${e.description}`).join('\n')}
`;
console.log(context);
@@ -179,8 +289,12 @@ interface ModuleAPI {
createSharedState(name: string, initial: T): Promise>;
createPersistentState(name: string, initial: T): Promise>;
createUserAgentState(name: string, initial: T): Promise>;
+ createLocalSessionState(name: string, initial: T): Promise>;
};
+ createStates>(states: T): Promise<{
+ [K in keyof T]: StateSupervisor;
+ }>;
createActions>(actions: T): T;
createAction(name: string, opts: {}, cb: ActionCallback): ActionFn;
@@ -223,6 +337,7 @@ interface CoreDependencies {
storage: {
remote: KVStore;
userAgent: KVStore;
+ session?: KVStore;
};
rpc: {
remote: Rpc;
@@ -276,6 +391,14 @@ declare module 'springboard/module_registry/module_registry' {
}
}
\`\`\`
+
+## React Module Access
+
+\`\`\`typescript
+import {useModule} from 'springboard/engine/engine';
+
+const moduleApi = useModule('myModule');
+\`\`\`
`;
console.log(types);
diff --git a/packages/springboard/create-springboard-app/src/cli.ts b/packages/springboard/create-springboard-app/src/cli.ts
index 24b42aca..5bb18a83 100644
--- a/packages/springboard/create-springboard-app/src/cli.ts
+++ b/packages/springboard/create-springboard-app/src/cli.ts
@@ -103,33 +103,172 @@ program
writeFileSync(`${process.cwd()}/vite.config.ts`, viteString);
console.log('Created vite config vite.config.ts');
- const agentDocsContent = `# Springboard Development Guide
+ const agentDocsContent = `Keep the following info in mind *when working in the ./src directory only*
-This application is built with the **Springboard framework**.
+After making any changes, run \`npm run check-types\` to ensure types pass.
-## Getting Started
+This application is built with the **Springboard framework**. All code is assumed to be isomorphic by default. Optionally run \`npx sb docs context\` for more info.
-**Before writing any code, run:**
+Example module:
-\`\`\`bash
-npx sb docs context
+\`\`\`tsx
+import springboard from 'springboard';
+
+type ExampleSharedState = {
+ version: 1;
+ items: [] as Array<{id: string; name: string}>;
+}
+
+springboard.registerModule('ModuleName', {}, async (moduleAPI) => {
+ const sharedState = await moduleAPI.createStates({
+
+ exampleSharedState: {
+ version: 1; // Later we can do \`version: 1 | 2\` and perform data migrations as needed
+ items: [],
+ } as ExampleSharedState,
+ });
+
+ const myClientState = await moduleAPI.createUserAgentState('mySettings', {theme: null} as {theme: string | null});
+
+ const myServerActions = moduleAPI.createActions({
+ addItem: (args: {name: string}) => {
+ const newItem = {id: generateid(), name: args.name};
+
+ sharedState.exampleSharedState.setStateImmer(state => {
+ state.push(newItem);
+ });
+
+ // or
+ sharedState.exampleSharedState.setState(state => {
+ return [...state, newItem];
+ });
+
+ const someOtherModule = moduleAPI.getModule('SomeOptionalModule');
+ someOtherModule?.actions.doSomething(); // Optional chaining, since module was registered as optional in its own type declaration. Good for modules that only exist on certain platform builds.
+
+ return {data: newItem};
+ },
+ })
+
+ // Register UI routes
+ moduleAPI.registerRoute('/', {}, (navigate) => {
+ const liveState = sharedState.useState();
+
+ return (
+
+
+
+ );
+ });
+
+ // Return public API
+ return { sharedState, actions };
+});
+
+// Declare module return value for other files
+declare module 'springboard/module_registry/module_registry' {
+ interface AllModules {
+ ModuleName: {
+ sharedState: {
+ exampleSharedState: StateSupervisor;
+ };
+ actions: {
+ addItem: (args: {name: string}) => Promise;
+ };
+ };
+ }
+}
\`\`\`
-This outputs comprehensive framework information including available documentation
-sections, key concepts, and workflow guidance.
+To access these values in another file
+
+\`\`\`tsx
+import {useModule} from '../hooks/useModule';
+
+const MyComponent = () => {
+ const myModule = useModule('ModuleName');
+ const liveState = myModule.sharedState.exampleSharedState.useState();
+
+ const doThing = async () => {
+ await myModule.actions.addItem({name: 'example'});
+ };
+};
+\`\`\`
+
+If importing a node module in an action, you'll need to use conditional compilation. Springboard is written in a way so that actions *can* run on the client, but our application here is only deployed as a server-driven SPA, so all actions will run on the server in this app.
+
+\`\`\`tsx
+const myActions = moduleAPI.createActions({
+ myAction: async () => {
+ // @platform "node"
+ const fs = await import ('fs');
+ // ...
+ // @platform end
+ },
+});
+
+// Or import a server only module
+
+// @platform "node"
+import './modules/MyServerOnlyModule';
+// @platform end
+
+// More rarely, you may want to remove code from the server build that only runs on the frontend. It's necessary sometimes.
+
+// @platform "browser"
+window.addEventListener('load', () => {
+
+});
+// @platform end
+\`\`\`
+
+
+# GitNexus — Code Intelligence
+
+This project is indexed by GitNexus as **vibe-kanban-vscode-web** (940 symbols, 1601 relationships, 64 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
+
+> If any GitNexus tool warns the index is stale, run \`npx gitnexus analyze\` in terminal first.
+
+## Always Do
+
+- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run \`gitnexus_impact({target: "symbolName", direction: "upstream"})\` and report the blast radius (direct callers, affected processes, risk level) to the user.
+- **MUST run \`gitnexus_detect_changes()\` before committing** to verify your changes only affect expected symbols and execution flows.
+- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
+- When exploring unfamiliar code, use \`gitnexus_query({query: "concept"})\` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
+- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use \`gitnexus_context({name: "symbolName"})\`.
+
+## Never Do
+
+- NEVER edit a function, class, or method without first running \`gitnexus_impact\` on it.
+- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
+- NEVER rename symbols with find-and-replace — use \`gitnexus_rename\` which understands the call graph.
+- NEVER commit changes without running \`gitnexus_detect_changes()\` to check affected scope.
+
+## Resources
-## Recommended Workflow
+| Resource | Use for |
+|----------|---------|
+| \`gitnexus://repo/vibe-kanban-vscode-web/context\` | Codebase overview, check index freshness |
+| \`gitnexus://repo/vibe-kanban-vscode-web/clusters\` | All functional areas |
+| \`gitnexus://repo/vibe-kanban-vscode-web/processes\` | All execution flows |
+| \`gitnexus://repo/vibe-kanban-vscode-web/process/{name}\` | Step-by-step execution trace |
-1. **Run \`sb docs context\`** at the start of your session
-2. **Write code** using your knowledge + the context from step 1
-3. **Fetch specific docs** only when needed: \`sb docs get \`
-4. **View examples** for reference code: \`sb docs examples show \`
+## CLI
-## Other Useful Commands
+| Task | Read this skill file |
+|------|---------------------|
+| Understand architecture / "How does X work?" | \`.claude/skills/gitnexus/gitnexus-exploring/SKILL.md\` |
+| Blast radius / "What breaks if I change X?" | \`.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md\` |
+| Trace bugs / "Why is X failing?" | \`.claude/skills/gitnexus/gitnexus-debugging/SKILL.md\` |
+| Rename / extract / split / refactor | \`.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md\` |
+| Tools, resources, schema reference | \`.claude/skills/gitnexus/gitnexus-guide/SKILL.md\` |
+| Index, status, clean, wiki CLI commands | \`.claude/skills/gitnexus/gitnexus-cli/SKILL.md\` |
-- \`sb docs --help\` - See all available commands
-- \`sb docs types\` - Get TypeScript type definitions
-- \`sb docs examples list\` - See available example modules
+
`;
writeFileSync(`${process.cwd()}/CLAUDE.md`, agentDocsContent);
writeFileSync(`${process.cwd()}/AGENTS.md`, agentDocsContent);
diff --git a/packages/springboard/package.json b/packages/springboard/package.json
index 71a7907b..305ccc95 100644
--- a/packages/springboard/package.json
+++ b/packages/springboard/package.json
@@ -132,6 +132,10 @@
"types": "./dist/platforms/browser/services/browser_kvstore_service.d.ts",
"import": "./dist/platforms/browser/services/browser_kvstore_service.js"
},
+ "./platforms/browser/services/browser_session_kvstore_service": {
+ "types": "./dist/platforms/browser/services/browser_session_kvstore_service.d.ts",
+ "import": "./dist/platforms/browser/services/browser_session_kvstore_service.js"
+ },
"./platforms/cloudflare-workers/entrypoints/cloudflare_entrypoint": {
"types": "./dist/platforms/cloudflare-workers/entrypoints/cloudflare_entrypoint.d.ts",
"import": "./dist/platforms/cloudflare-workers/entrypoints/cloudflare_entrypoint.js"
diff --git a/packages/springboard/src/core/engine/register.ts b/packages/springboard/src/core/engine/register.ts
index 9c225f2a..6a85acae 100644
--- a/packages/springboard/src/core/engine/register.ts
+++ b/packages/springboard/src/core/engine/register.ts
@@ -33,22 +33,50 @@ export type RegisterModuleOptions = {
type CapturedRegisterModuleCall = [string, RegisterModuleOptions, ModuleCallback];
+const getRegisterModuleCalls = (): CapturedRegisterModuleCall[] => {
+ const store = registerModule as unknown as {
+ calls?: CapturedRegisterModuleCall[];
+ };
+ return store.calls ? [...store.calls] : [];
+};
+
+const setRegisterModuleCalls = (calls: CapturedRegisterModuleCall[]): void => {
+ const store = registerModule as unknown as {
+ calls?: CapturedRegisterModuleCall[];
+ };
+ store.calls = calls;
+};
+
const registerModule = (
moduleName: string,
options: ModuleOptions,
cb: ModuleCallback,
) => {
- const calls = (registerModule as unknown as {calls: CapturedRegisterModuleCall[]}).calls || [];
+ const calls = getRegisterModuleCalls();
calls.push([moduleName, options, cb]);
- (registerModule as unknown as {calls: CapturedRegisterModuleCall[]}).calls = calls;
+ setRegisterModuleCalls(calls);
};
type CapturedRegisterClassModuleCalls = ClassModuleCallback;
+const getRegisterClassModuleCalls = (): CapturedRegisterClassModuleCalls[] => {
+ const store = registerClassModule as unknown as {
+ calls?: CapturedRegisterClassModuleCalls[];
+ };
+ return store.calls ? [...store.calls] : [];
+};
+
+const setRegisterClassModuleCalls = (calls: CapturedRegisterClassModuleCalls[]): void => {
+ const store = registerClassModule as unknown as {
+ calls?: CapturedRegisterClassModuleCalls[];
+ };
+ store.calls = calls;
+};
+
const registerClassModule = (cb: ClassModuleCallback) => {
- const calls = (registerClassModule as unknown as {calls: CapturedRegisterClassModuleCalls[]}).calls || [];
+ const calls = getRegisterClassModuleCalls();
calls.push(cb);
- (registerClassModule as unknown as {calls: CapturedRegisterClassModuleCalls[]}).calls = calls;
+ setRegisterClassModuleCalls(calls);
};
let registeredSplashScreen: React.ComponentType | null = null;
@@ -61,6 +89,18 @@ export const getRegisteredSplashScreen = (): React.ComponentType | null => {
return registeredSplashScreen;
};
+export const clearRegisteredModules = (): void => {
+ setRegisterModuleCalls([]);
+};
+
+export const clearRegisteredClassModules = (): void => {
+ setRegisterClassModuleCalls([]);
+};
+
+export const clearRegisteredSplashScreen = (): void => {
+ registeredSplashScreen = null;
+};
+
export const springboard: SpringboardRegistry = {
registerModule,
registerClassModule,
diff --git a/packages/springboard/src/server/hono_app.ts b/packages/springboard/src/server/hono_app.ts
index ae15ed28..552ebe51 100644
--- a/packages/springboard/src/server/hono_app.ts
+++ b/packages/springboard/src/server/hono_app.ts
@@ -23,6 +23,7 @@ type InitServerAppArgs = {
remoteKV: KVStore;
userAgentKV: KVStore;
broadcastMessage: (message: string) => void;
+ enableStaticRoutes?: boolean;
};
type InjectResourcesArgs = {
@@ -40,6 +41,8 @@ export const initApp = (initArgs: InitServerAppArgs): InitAppReturnValue => {
app.use('*', cors());
+ const enableStaticRoutes = initArgs.enableStaticRoutes ?? true;
+
const remoteKV = initArgs.remoteKV;
const userAgentKV = initArgs.userAgentKV;
@@ -187,38 +190,40 @@ export const initApp = (initArgs: InitServerAppArgs): InitAppReturnValue => {
let serveStaticFileFn: ((c: Context, fileName: string, headers: Record) => Promise) | undefined;
let getEnvValueFn: ((name: string) => string | undefined) | undefined;
- app.use('/', async (c) => {
- if (!serveStaticFileFn) {
- return c.text('Server not fully initialized', 500);
- }
- const headers = {
- 'Cache-Control': 'no-store, no-cache, must-revalidate',
- 'Pragma': 'no-cache',
- 'Expires': '0',
- 'Content-Type': 'text/html'
- };
- return serveStaticFileFn(c, 'index.html', headers);
- });
-
- app.use('/assets/:file', async (c, next) => {
- if (!serveStaticFileFn || !getEnvValueFn) {
- return c.text('Server not fully initialized', 500);
- }
+ if (enableStaticRoutes) {
+ app.use('/', async (c) => {
+ if (!serveStaticFileFn) {
+ return c.text('Server not fully initialized', 500);
+ }
+ const headers = {
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
+ 'Pragma': 'no-cache',
+ 'Expires': '0',
+ 'Content-Type': 'text/html'
+ };
+ return serveStaticFileFn(c, 'index.html', headers);
+ });
+
+ app.use('/assets/:file', async (c, next) => {
+ if (!serveStaticFileFn || !getEnvValueFn) {
+ return c.text('Server not fully initialized', 500);
+ }
- const requestedFile = c.req.param('file');
+ const requestedFile = c.req.param('file');
- if (requestedFile.endsWith('.map') && getEnvValueFn('NODE_ENV') === 'production') {
- return c.text('Source map disabled', 404);
- }
+ if (requestedFile.endsWith('.map') && getEnvValueFn('NODE_ENV') === 'production') {
+ return c.text('Source map disabled', 404);
+ }
- const contentType = requestedFile.endsWith('.js') ? 'text/javascript' : 'text/css';
- const headers = {
- 'Content-Type': contentType,
- 'Cache-Control': 'public, max-age=31536000, immutable'
- };
+ const contentType = requestedFile.endsWith('.js') ? 'text/javascript' : 'text/css';
+ const headers = {
+ 'Content-Type': contentType,
+ 'Cache-Control': 'public, max-age=31536000, immutable'
+ };
- return serveStaticFileFn(c, `assets/${requestedFile}`, headers);
- });
+ return serveStaticFileFn(c, `assets/${requestedFile}`, headers);
+ });
+ }
// app.use('/dist/manifest.json', serveStatic({
// root: webappDistFolder,
@@ -287,19 +292,21 @@ export const initApp = (initArgs: InitServerAppArgs): InitAppReturnValue => {
// },
// }));
- app.use('*', async (c) => {
- if (!serveStaticFileFn) {
- return c.text('Server not fully initialized', 500);
- }
- const headers = {
- 'Cache-Control': 'no-store, no-cache, must-revalidate',
- 'Pragma': 'no-cache',
- 'Expires': '0',
- 'Content-Type': 'text/html'
- };
-
- return serveStaticFileFn(c, 'index.html', headers);
- });
+ if (enableStaticRoutes) {
+ app.use('*', async (c) => {
+ if (!serveStaticFileFn) {
+ return c.text('Server not fully initialized', 500);
+ }
+ const headers = {
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
+ 'Pragma': 'no-cache',
+ 'Expires': '0',
+ 'Content-Type': 'text/html'
+ };
+
+ return serveStaticFileFn(c, 'index.html', headers);
+ });
+ }
const injectResources = (args: InjectResourcesArgs) => {
if (storedEngine) {
diff --git a/packages/springboard/src/server/register.ts b/packages/springboard/src/server/register.ts
index 3bc17116..8025c87e 100644
--- a/packages/springboard/src/server/register.ts
+++ b/packages/springboard/src/server/register.ts
@@ -11,15 +11,37 @@ export type ServerModuleCallback = (server: ServerModuleAPI) => void;
type CapturedRegisterServerModuleCall = ServerModuleCallback;
+const getRegisterServerModuleCalls = (): CapturedRegisterServerModuleCall[] => {
+ const store = registerServerModule as unknown as {
+ calls?: CapturedRegisterServerModuleCall[];
+ };
+ return store.calls ? [...store.calls] : [];
+};
+
+const setRegisterServerModuleCalls = (calls: CapturedRegisterServerModuleCall[]): void => {
+ const store = registerServerModule as unknown as {
+ calls?: CapturedRegisterServerModuleCall[];
+ };
+ store.calls = calls;
+};
+
const registerServerModule = (
cb: ServerModuleCallback,
) => {
- const calls = (registerServerModule as unknown as {calls: CapturedRegisterServerModuleCall[]}).calls || [];
+ const calls = getRegisterServerModuleCalls();
calls.push(cb);
- (registerServerModule as unknown as {calls: CapturedRegisterServerModuleCall[]}).calls = calls;
+ setRegisterServerModuleCalls(calls);
};
export type ServerModuleRegistry = {
+ /**
+ * Register server routes and server-scoped hooks.
+ *
+ * Server modules may attach route handlers and RPC middleware, but should
+ * not replace global Hono fallback behavior such as `notFound()`.
+ * Development mode relies on the framework-owned fallback remaining intact
+ * so browser requests can fall through to Vite when no server route matches.
+ */
registerServerModule: (
cb: ServerModuleCallback,
) => void;
@@ -29,6 +51,15 @@ export const serverRegistry: ServerModuleRegistry = {
registerServerModule,
};
+export const clearRegisteredServerModules = (): void => {
+ setRegisterServerModuleCalls([]);
+};
+
+export const resetServerRegistry = (): void => {
+ clearRegisteredServerModules();
+ serverRegistry.registerServerModule = registerServerModule;
+};
+
export type RpcMiddleware = (c: Context) => Promise
-
- ${scriptTag}
-', ` ${jsScripts}\n`);
-
- // Emit the HTML file to .springboard directory
- this.emitFile({
- type: 'asset',
- fileName: '.springboard/index.html',
- source: finalHtml,
- });
-
- logger.info('Generated index.html');
- },
- };
-}
-
-/**
- * Generate HTML content with document metadata
- */
-function generateHtml(options: NormalizedOptions, isDev: boolean): string {
- const meta = options.documentMeta || {};
- const metaTags = generateMetaTags(meta);
-
- // Dev mode uses virtual module URL, build mode will have scripts injected
- const scriptTag = isDev
- ? ''
- : '';
-
- return `
-
-