From 900da7a165e06924c14b7dbfe967459048e2159d Mon Sep 17 00:00:00 2001 From: Vibe Kanban Date: Wed, 25 Feb 2026 17:12:05 +0000 Subject: [PATCH 01/22] Move springboard dev to single-port ModuleRunner --- .../springboard/src/core/engine/register.ts | 51 ++- packages/springboard/src/server/hono_app.ts | 89 ++-- packages/springboard/src/server/register.ts | 27 +- .../vite-plugin/src/plugins/dev.ts | 394 ++++++++++-------- .../src/templates/node-dev-entry.template.ts | 88 ++++ 5 files changed, 437 insertions(+), 212 deletions(-) create mode 100644 packages/springboard/vite-plugin/src/templates/node-dev-entry.template.ts diff --git a/packages/springboard/src/core/engine/register.ts b/packages/springboard/src/core/engine/register.ts index 9c225f2a..4abff99d 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, @@ -69,6 +109,9 @@ export const springboard: SpringboardRegistry = { springboard.registerModule = registerModule; springboard.registerClassModule = registerClassModule; springboard.registerSplashScreen = registerSplashScreen; + clearRegisteredModules(); + clearRegisteredClassModules(); + clearRegisteredSplashScreen(); }, }; 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..f70d979e 100644 --- a/packages/springboard/src/server/register.ts +++ b/packages/springboard/src/server/register.ts @@ -11,12 +11,26 @@ 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 = { @@ -29,6 +43,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; type ServerHooks = { diff --git a/packages/springboard/vite-plugin/src/plugins/dev.ts b/packages/springboard/vite-plugin/src/plugins/dev.ts index 9ae312d7..467f9a33 100644 --- a/packages/springboard/vite-plugin/src/plugins/dev.ts +++ b/packages/springboard/vite-plugin/src/plugins/dev.ts @@ -4,108 +4,158 @@ * Handles development server setup with HMR and ModuleRunner for node platform. */ -import type { Plugin, ViteDevServer, ResolvedConfig } from 'vite'; -import type { NormalizedOptions, NodeEntryModule, Platform } from '../types.js'; +import type { Plugin, ViteDevServer } from 'vite'; +import type { NormalizedOptions } from '../types.js'; import { createLogger } from './shared.js'; import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'; import * as path from 'path'; import { fileURLToPath } from 'url'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { Readable, type Duplex } from 'node:stream'; + +const FALLBACK_HEADER = 'x-springboard-fallback'; // Vite 6+ types (not available in Vite 5 types but available at runtime with Vite 6+) type ModuleRunner = { - import: (url: string) => Promise; + import: (url: string) => Promise; close: () => void; }; +type ModuleGraph = { + getModuleById: (id: string) => { id: string } | null; + getModuleByUrl?: (url: string) => { id: string } | null; + invalidateModule: (mod: { id: string }) => void; +}; + type ViteEnvironments = { - ssr: unknown; + ssr: { + moduleGraph: ModuleGraph; + }; }; type ViteDevServerWithEnvironments = ViteDevServer & { environments: ViteEnvironments; }; +type WsAdapter = { + handleUpgrade: (req: IncomingMessage, socket: Duplex, head: Buffer) => Promise; + closeAll: (code?: number, data?: string | Buffer, force?: boolean) => void; +}; + +type DevServerHandle = { + fetch: (request: Request) => Promise; + ws?: WsAdapter; + dispose?: () => Promise | void; +}; + +type DevServerModule = { + createDevServer: () => Promise | DevServerHandle; +}; + /** - * Load the node entry template from the templates directory + * Load the node dev entry template from the templates directory */ -function loadNodeEntryTemplate(): string { - // Get the directory of this file (will be in dist/plugins/ when built) +function loadNodeDevEntryTemplate(): string { const currentDir = path.dirname(fileURLToPath(import.meta.url)); - // Templates are in src/templates/, so from dist/plugins/ we go up to package root, then into src/templates/ - const templatePath = path.resolve(currentDir, '../../src/templates/node-entry.template.ts'); + const templatePath = path.resolve(currentDir, '../../src/templates/node-dev-entry.template.ts'); return readFileSync(templatePath, 'utf-8'); } /** - * Generate node entry code with user entry path and port injected + * Generate node dev entry code with user entry path injected */ -function generateNodeEntryCode(userEntryPath: string, port: number = 3000): string { - const template = loadNodeEntryTemplate(); - return template - .replace('__USER_ENTRY__', userEntryPath) - .replace('__PORT__', String(port)); +function generateNodeDevEntryCode(userEntryPath: string): string { + const template = loadNodeDevEntryTemplate(); + return template.replace('__USER_ENTRY__', userEntryPath); } +const createRequestFromNode = (req: IncomingMessage): Request => { + const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`); + const headers = new Headers(); + + for (const [key, value] of Object.entries(req.headers)) { + if (typeof value === 'string') { + headers.set(key, value); + } else if (Array.isArray(value)) { + for (const entry of value) { + headers.append(key, entry); + } + } + } + + const method = req.method ?? 'GET'; + const hasBody = method !== 'GET' && method !== 'HEAD'; + if (hasBody) { + return new Request(url, { + method, + headers, + body: Readable.toWeb(req), + duplex: 'half', + }); + } + + return new Request(url, { method, headers }); +}; + +const applyResponseToNode = async (res: ServerResponse, response: Response, method: string): Promise => { + res.statusCode = response.status; + if (response.statusText) { + res.statusMessage = response.statusText; + } + + const headers = response.headers; + for (const [key, value] of headers.entries()) { + if (key.toLowerCase() === 'set-cookie') { + continue; + } + res.setHeader(key, value); + } + + const setCookie = 'getSetCookie' in headers + ? (headers as unknown as { getSetCookie: () => string[] }).getSetCookie() + : []; + if (setCookie.length > 0) { + res.setHeader('set-cookie', setCookie); + } + + if (method === 'HEAD' || response.body === null) { + res.end(); + return; + } + + const body = Readable.fromWeb(response.body); + body.on('error', (err) => { + res.destroy(err); + }); + body.pipe(res); +}; + /** * Create the springboard dev plugin. - * - * Responsibilities: - * - Configure HMR for browser platforms - * - Start node server via ModuleRunner for server platforms - * - Handle hot module replacement - * - * @param options - Normalized plugin options - * @returns Vite plugin */ export function springboardDev(options: NormalizedOptions): Plugin { const logger = createLogger('dev', options.debug); - let resolvedConfig: ResolvedConfig; - let server: ViteDevServer | null = null; + let server: ViteDevServerWithEnvironments | null = null; let runner: ModuleRunner | null = null; - let nodeEntryModule: NodeEntryModule | null = null; + let currentFetch: ((request: Request) => Promise) | null = null; + let currentWs: WsAdapter | null = null; + let currentDispose: (() => Promise | void) | null = null; + let reloadInFlight: Promise | null = null; // Check if node platform is active const hasNode = options.platforms.includes('node'); const hasBrowser = options.platforms.includes('browser'); - const nodePort = options.nodeServerPort; return { name: 'springboard:dev', apply: 'serve', /** - * Store resolved config - */ - configResolved(config) { - resolvedConfig = config; - }, - - /** - * Configure dev server with proxy and SSR for multi-platform setup + * Configure dev server with SSR support for multi-platform setup */ - config(config, env) { - // Only configure proxy and SSR when both node and browser platforms are active - if (hasNode && hasBrowser) { - logger.info('Configuring Vite proxy and SSR for multi-platform dev mode'); - + config() { + if (hasNode || hasBrowser) { return { - server: { - proxy: { - '/rpc': { - target: `http://localhost:${nodePort}`, - changeOrigin: true, - }, - '/kv': { - target: `http://localhost:${nodePort}`, - changeOrigin: true, - }, - '/ws': { - target: `ws://localhost:${nodePort}`, - ws: true, - changeOrigin: true, - }, - }, - }, ssr: { // noExternal fixes missing .js extensions in springboard imports noExternal: ['springboard'], @@ -121,132 +171,146 @@ export function springboardDev(options: NormalizedOptions): Plugin { /** * Configure the dev server */ - configureServer(devServer: ViteDevServer) { - server = devServer; + async configureServer(devServer: ViteDevServer) { + server = devServer as ViteDevServerWithEnvironments; logger.info(`Dev server starting for platform: ${options.platform}`); - // Return middleware setup function - return () => { - // Custom middleware for Springboard-specific routes - devServer.middlewares.use((req, res, next) => { - // Handle /__springboard/ routes for debugging - if (req.url?.startsWith('/__springboard/')) { - handleSpringboardRoute(req, res, options); - return; - } - next(); - }); - - // Only start node server if 'node' is one of the target platforms - if (!hasNode) { - logger.debug('Node platform not active - skipping node server startup'); + // Custom middleware for Springboard-specific routes + devServer.middlewares.use((req, res, next) => { + if (req.url?.startsWith('/__springboard/')) { + handleSpringboardRoute(req, res, options); return; } + next(); + }); + + if (!hasNode) { + logger.debug('Node platform not active - skipping node server setup'); + return; + } + + const springboardDir = path.resolve(options.root, '.springboard'); + const nodeDevEntryFile = path.join(springboardDir, 'node-dev-entry.ts'); - // Generate and start node server - const springboardDir = path.resolve(options.root, '.springboard'); - const nodeEntryFile = path.join(springboardDir, 'node-entry.ts'); + if (!existsSync(springboardDir)) { + mkdirSync(springboardDir, { recursive: true }); + } + + const absoluteEntryPath = path.isAbsolute(options.entry) + ? options.entry + : path.resolve(options.root, options.entry); + const relativeEntryPath = path.relative(springboardDir, absoluteEntryPath); + + const nodeDevEntryCode = generateNodeDevEntryCode(relativeEntryPath); + writeFileSync(nodeDevEntryFile, nodeDevEntryCode, 'utf-8'); + logger.info('Generated node dev entry file for single-port mode'); - // Ensure .springboard directory exists - if (!existsSync(springboardDir)) { - mkdirSync(springboardDir, { recursive: true }); + const stopServer = async () => { + if (currentDispose) { + await currentDispose(); } + if (currentWs) { + currentWs.closeAll(); + } + currentFetch = null; + currentWs = null; + currentDispose = null; + if (runner) { + runner.close(); + runner = null; + } + }; - // Calculate relative path from .springboard/ to user entry - const absoluteEntryPath = path.isAbsolute(options.entry) - ? options.entry - : path.resolve(options.root, options.entry); - const relativeEntryPath = path.relative(springboardDir, absoluteEntryPath); - - // Generate node entry file - const nodeEntryCode = generateNodeEntryCode(relativeEntryPath, nodePort); - writeFileSync(nodeEntryFile, nodeEntryCode, 'utf-8'); - logger.info('Generated node entry file for dev mode'); - - // Start the node server using ModuleRunner - const startNodeServer = async () => { - try { - // Dynamically import createServerModuleRunner (Vite 6+ API) - // Type assertion needed because we're building with Vite 5 types but running with Vite 6+ - const viteModule = await import('vite') as unknown as { - createServerModuleRunner: (env: unknown) => ModuleRunner; - }; - - // Create module runner with HMR support - const serverWithEnv = server as ViteDevServerWithEnvironments; - runner = viteModule.createServerModuleRunner(serverWithEnv.environments.ssr); - - // Load and execute the node entry module - nodeEntryModule = await runner.import(nodeEntryFile); - - // Call the exported start() function - if (nodeEntryModule && typeof nodeEntryModule.start === 'function') { - await nodeEntryModule.start(); - logger.info('Node server started via ModuleRunner'); - } else { - logger.error('Node entry does not export a start() function'); - } - } catch (err) { - logger.error(`Failed to start node server: ${err}`); - } + const startServer = async () => { + const viteModule = await import('vite') as unknown as { + createServerModuleRunner: (env: ViteEnvironments['ssr']) => ModuleRunner; }; - const stopNodeServer = async () => { - if (runner) { - try { - // First, manually call stop() on the node entry module to close the HTTP server - // This is necessary because when Vite restarts (e.g., config change), - // the HMR dispose handler doesn't get called - if (nodeEntryModule?.stop && typeof nodeEntryModule.stop === 'function') { - await nodeEntryModule.stop(); - logger.info('Node server stopped manually'); - } - - // Then close the runner (renamed from destroy() in Vite 6+) - runner.close(); - runner = null; - nodeEntryModule = null; - logger.info('Node server runner closed'); - } catch (err) { - logger.error(`Failed to stop node server: ${err}`); - } - } - }; + runner = viteModule.createServerModuleRunner(server!.environments.ssr); + + const mod = await runner.import(nodeDevEntryFile); + if (!mod || typeof mod.createDevServer !== 'function') { + logger.error('Dev entry does not export createDevServer()'); + return; + } + + const handle = await mod.createDevServer(); + currentFetch = handle.fetch; + currentWs = handle.ws ?? null; + currentDispose = handle.dispose ?? null; + }; - // Start the node server when Vite dev server starts - startNodeServer(); + const reloadServer = async () => { + if (reloadInFlight) { + return reloadInFlight; + } + + reloadInFlight = (async () => { + await stopServer(); + await startServer(); + })(); - logger.info('Vite proxy configured via server.proxy:'); - logger.info(` /rpc/* -> http://localhost:${nodePort}/rpc/*`); - logger.info(` /kv/* -> http://localhost:${nodePort}/kv/*`); - logger.info(` /ws -> ws://localhost:${nodePort}/ws (WebSocket)`); + try { + await reloadInFlight; + } finally { + reloadInFlight = null; + } - // Clean up when Vite dev server closes - server!.httpServer?.on('close', () => { - stopNodeServer(); - }); + return undefined; }; - }, - /** - * Handle HMR updates - */ - handleHotUpdate({ file, server, modules }) { - // Log file changes in debug mode - if (options.debug) { - logger.debug(`HMR update: ${file}`); - } + await startServer(); - // Let Vite handle HMR normally - return undefined; - }, + devServer.middlewares.use(async (req, res, next) => { + if (!currentFetch) { + next(); + return; + } - /** - * Cleanup on server close - */ - async buildEnd() { - // Cleanup handled in configureServer hook + try { + const request = createRequestFromNode(req); + const response = await currentFetch(request); + + if (response.headers.get(FALLBACK_HEADER) === '1') { + next(); + return; + } + + await applyResponseToNode(res, response, request.method); + } catch (err) { + next(err as Error); + } + }); + + devServer.httpServer?.on('upgrade', (req, socket, head) => { + if (!currentWs) { + return; + } + + const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`); + if (url.pathname !== '/ws') { + return; + } + + void currentWs.handleUpgrade(req, socket, head); + }); + + devServer.httpServer?.on('close', () => { + void stopServer(); + }); + + devServer.watcher.on('change', (file) => { + const ssrModule = server?.environments.ssr.moduleGraph.getModuleById(file) + ?? server?.environments.ssr.moduleGraph.getModuleByUrl?.(file) + ?? null; + if (!ssrModule) { + return; + } + + server?.environments.ssr.moduleGraph.invalidateModule(ssrModule); + void reloadServer(); + }); }, }; } diff --git a/packages/springboard/vite-plugin/src/templates/node-dev-entry.template.ts b/packages/springboard/vite-plugin/src/templates/node-dev-entry.template.ts new file mode 100644 index 00000000..0e04f5c7 --- /dev/null +++ b/packages/springboard/vite-plugin/src/templates/node-dev-entry.template.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; +}; + +export async function createDevServer(): Promise { + springboard.reset(); + clearRegisteredModules(); + clearRegisteredClassModules(); + clearRegisteredSplashScreen(); + resetServerRegistry(); + + await import('__USER_ENTRY__'); + + 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(); + }, + }; +} From 24883d9c02b7cd95e14992ca7e0ae070d8cc626e Mon Sep 17 00:00:00 2001 From: Vibe Kanban Date: Thu, 26 Feb 2026 23:50:07 +0000 Subject: [PATCH 02/22] Add midi_files_module to @jamtools/core package exports Export the midi_files_module so it can be imported directly for its module augmentation, fixing TypeScript type resolution issues when importing as a side-effect. Co-Authored-By: Claude Opus 4.6 --- packages/jamtools/core/package.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/jamtools/core/package.json b/packages/jamtools/core/package.json index 9df9b38f..d3a97083 100644 --- a/packages/jamtools/core/package.json +++ b/packages/jamtools/core/package.json @@ -50,6 +50,10 @@ "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/macro_module/registered_macro_types": { "types": "./dist/modules/macro_module/registered_macro_types.d.ts", "import": "./dist/modules/macro_module/registered_macro_types.js" From 4e02efa56427347edbac6cf9efa3d1d08c56a2bf Mon Sep 17 00:00:00 2001 From: Vibe Kanban Date: Thu, 26 Feb 2026 23:59:33 +0000 Subject: [PATCH 03/22] Refactor io_module to use separate .browser.ts and .node.ts files Instead of using @platform directives with dynamic imports inside the main io_module.tsx file, extract the platform-specific dependency creation into separate files: - io_dependencies.ts - default/test implementation - io_dependencies.browser.ts - browser-specific imports - io_dependencies.node.ts - node-specific imports This allows Vite to properly tree-shake the platform-specific code during bundling, preventing Node.js modules (child_process, easymidi) from being included in browser builds. The @platform directive now works at the import level, which is processed before Vite's bundling phase, fixing the issue where browser builds tried to bundle Node-only dependencies. Co-Authored-By: Claude Opus 4.6 --- .../src/modules/io/io_dependencies.browser.ts | 17 ++++++ .../src/modules/io/io_dependencies.node.ts | 26 ++++++++++ .../core/src/modules/io/io_dependencies.ts | 16 ++++++ .../core/src/modules/io/io_module.tsx | 52 ++++--------------- 4 files changed, 69 insertions(+), 42 deletions(-) create mode 100644 packages/jamtools/core/src/modules/io/io_dependencies.browser.ts create mode 100644 packages/jamtools/core/src/modules/io/io_dependencies.node.ts create mode 100644 packages/jamtools/core/src/modules/io/io_dependencies.ts 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..761871d2 --- /dev/null +++ b/packages/jamtools/core/src/modules/io/io_dependencies.browser.ts @@ -0,0 +1,17 @@ +import {MidiService, QwertyService} from '@jamtools/core/types/io_types'; +import {BrowserQwertyService} from '@jamtools/core/services/browser/browser_qwerty_service'; +import {BrowserMidiService} from '@jamtools/core/services/browser/browser_midi_service'; + +export type IoDeps = { + midi: MidiService; + qwerty: QwertyService; +} + +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..283ac2e0 --- /dev/null +++ b/packages/jamtools/core/src/modules/io/io_dependencies.node.ts @@ -0,0 +1,26 @@ +import {MidiService, QwertyService} from '@jamtools/core/types/io_types'; +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'; + +export type IoDeps = { + midi: MidiService; + qwerty: QwertyService; +} + +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..3a702010 --- /dev/null +++ b/packages/jamtools/core/src/modules/io/io_dependencies.ts @@ -0,0 +1,16 @@ +import {MidiService, QwertyService} from '@jamtools/core/types/io_types'; +import {MockMidiService} from '@jamtools/core/test/services/mock_midi_service'; +import {MockQwertyService} from '@jamtools/core/test/services/mock_qwerty_service'; + +export type IoDeps = { + midi: MidiService; + qwerty: QwertyService; +} + +// 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_module.tsx b/packages/jamtools/core/src/modules/io/io_module.tsx index 4474310b..51f42631 100644 --- a/packages/jamtools/core/src/modules/io/io_module.tsx +++ b/packages/jamtools/core/src/modules/io/io_module.tsx @@ -7,56 +7,24 @@ 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} from './io_dependencies'; -type IoDeps = { - midi: MidiService; - qwerty: QwertyService; -} - -let createIoDependencies = async (): Promise => { - return { - qwerty: new MockQwertyService(), - midi: new MockMidiService(), - }; -}; +let createIoDependencies: () => Promise; // @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, - }; -}; +import {createIoDependencies as browserDeps} from './io_dependencies.browser'; +createIoDependencies = browserDeps; // @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'); +import {createIoDependencies as nodeDeps} from './io_dependencies.node'; +createIoDependencies = nodeDeps; +// @platform end - const qwerty = new NodeQwertyService(); - const midi = new NodeMidiService(); - return { - qwerty, - midi, - }; -}; +// @platform "default" +import {createIoDependencies as defaultDeps} from './io_dependencies'; +createIoDependencies = defaultDeps; // @platform end export const setIoDependencyCreator = (func: typeof createIoDependencies) => { From e0366d8b4c2459c4a50c864dd02cbbcb5cc50f1f Mon Sep 17 00:00:00 2001 From: Vibe Kanban Date: Fri, 27 Feb 2026 00:03:40 +0000 Subject: [PATCH 04/22] Use package.json export conditions for platform-specific io_dependencies Refactored to use Vite's export conditions instead of @platform directives: - Created io_dependencies_types.ts - types-only file with no implementation imports - Updated io_dependencies.ts/.browser.ts/.node.ts to import types from types file - Removed all @platform directives from io_module.tsx - Added conditional export in package.json with node/browser/default conditions Now the bundler (Vite/webpack) will automatically resolve: - ./io_dependencies.browser.js when building for browser (avoids Node modules) - ./io_dependencies.node.js when building for Node - ./io_dependencies.js as fallback for tests This ensures Node.js dependencies (child_process, easymidi) are never analyzed or bundled in browser builds, fixing the build error. Co-Authored-By: Claude Opus 4.6 --- packages/jamtools/core/package.json | 12 +++++++++ .../src/modules/io/io_dependencies.browser.ts | 7 +----- .../src/modules/io/io_dependencies.node.ts | 7 +----- .../core/src/modules/io/io_dependencies.ts | 7 +----- .../src/modules/io/io_dependencies_types.ts | 8 ++++++ .../core/src/modules/io/io_module.tsx | 25 ++++--------------- 6 files changed, 28 insertions(+), 38 deletions(-) create mode 100644 packages/jamtools/core/src/modules/io/io_dependencies_types.ts diff --git a/packages/jamtools/core/package.json b/packages/jamtools/core/package.json index d3a97083..361aebf9 100644 --- a/packages/jamtools/core/package.json +++ b/packages/jamtools/core/package.json @@ -54,6 +54,18 @@ "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 index 761871d2..193dd2fa 100644 --- a/packages/jamtools/core/src/modules/io/io_dependencies.browser.ts +++ b/packages/jamtools/core/src/modules/io/io_dependencies.browser.ts @@ -1,11 +1,6 @@ -import {MidiService, QwertyService} from '@jamtools/core/types/io_types'; import {BrowserQwertyService} from '@jamtools/core/services/browser/browser_qwerty_service'; import {BrowserMidiService} from '@jamtools/core/services/browser/browser_midi_service'; - -export type IoDeps = { - midi: MidiService; - qwerty: QwertyService; -} +import type {IoDeps} from './io_dependencies_types'; export const createIoDependencies = async (): Promise => { const qwerty = new BrowserQwertyService(document); diff --git a/packages/jamtools/core/src/modules/io/io_dependencies.node.ts b/packages/jamtools/core/src/modules/io/io_dependencies.node.ts index 283ac2e0..d1850626 100644 --- a/packages/jamtools/core/src/modules/io/io_dependencies.node.ts +++ b/packages/jamtools/core/src/modules/io/io_dependencies.node.ts @@ -1,13 +1,8 @@ -import {MidiService, QwertyService} from '@jamtools/core/types/io_types'; 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'; - -export type IoDeps = { - midi: MidiService; - qwerty: QwertyService; -} +import type {IoDeps} from './io_dependencies_types'; export const createIoDependencies = async (): Promise => { if (process.env.DISABLE_IO === 'true') { diff --git a/packages/jamtools/core/src/modules/io/io_dependencies.ts b/packages/jamtools/core/src/modules/io/io_dependencies.ts index 3a702010..647075b3 100644 --- a/packages/jamtools/core/src/modules/io/io_dependencies.ts +++ b/packages/jamtools/core/src/modules/io/io_dependencies.ts @@ -1,11 +1,6 @@ -import {MidiService, QwertyService} from '@jamtools/core/types/io_types'; import {MockMidiService} from '@jamtools/core/test/services/mock_midi_service'; import {MockQwertyService} from '@jamtools/core/test/services/mock_qwerty_service'; - -export type IoDeps = { - midi: MidiService; - qwerty: QwertyService; -} +import type {IoDeps} from './io_dependencies_types'; // Default implementation for testing export const createIoDependencies = async (): Promise => { 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 51f42631..a3c13a97 100644 --- a/packages/jamtools/core/src/modules/io/io_module.tsx +++ b/packages/jamtools/core/src/modules/io/io_module.tsx @@ -8,27 +8,12 @@ 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 type {IoDeps} from './io_dependencies'; +import type {IoDeps, CreateIoDependencies} from './io_dependencies_types'; +import {createIoDependencies} from './io_dependencies'; -let createIoDependencies: () => Promise; - -// @platform "browser" -import {createIoDependencies as browserDeps} from './io_dependencies.browser'; -createIoDependencies = browserDeps; -// @platform end - -// @platform "node" -import {createIoDependencies as nodeDeps} from './io_dependencies.node'; -createIoDependencies = nodeDeps; -// @platform end - -// @platform "default" -import {createIoDependencies as defaultDeps} from './io_dependencies'; -createIoDependencies = defaultDeps; -// @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 + (createIoDependencies as any) = func; }; type IoState = { From 578619345abe8450ef4946f3f067493e02d36e1c Mon Sep 17 00:00:00 2001 From: Vibe Kanban Date: Fri, 27 Feb 2026 04:46:11 +0000 Subject: [PATCH 05/22] Fix ES module import reassignment in io_module for Vite dev mode Changed setIoDependencyCreator to use object wrapper pattern instead of directly reassigning imported function. This fixes esbuild error during Vite's dependency optimization phase: "Cannot assign to import 'createIoDependencies'" The function is only used in tests to override platform-specific implementations, but esbuild statically analyzes the code and rejects the illegal assignment even though it's never called in dev mode. Solution: Wrap createIoDependencies in ioDepsConfig object, allowing property mutation (legal in ES modules) instead of binding reassignment (illegal in ES modules). Also includes verdaccio setup improvements and .npmrc for local registry. Co-Authored-By: Claude Opus 4.6 --- .npmrc | 2 ++ .../core/src/modules/io/io_module.tsx | 11 ++++++++--- packages/springboard/package.json | 4 ++++ .../vite-plugin/src/plugins/dev.ts | 2 +- scripts/run-all-folders.sh | 1 + verdaccio/docker-compose.yml | 4 ++-- verdaccio/run.sh | 19 +++++++++++++++++++ 7 files changed, 37 insertions(+), 6 deletions(-) create mode 100644 .npmrc create mode 100755 verdaccio/run.sh diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..57fef07c --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +registry=http://localhost:4873/ +//localhost:4873/:_authToken=fake \ No newline at end of file diff --git a/packages/jamtools/core/src/modules/io/io_module.tsx b/packages/jamtools/core/src/modules/io/io_module.tsx index a3c13a97..b79d468b 100644 --- a/packages/jamtools/core/src/modules/io/io_module.tsx +++ b/packages/jamtools/core/src/modules/io/io_module.tsx @@ -9,11 +9,16 @@ import {ModuleAPI} from 'springboard/engine/module_api'; import {MidiEvent} from '@jamtools/core/modules/macro_module/macro_module_types'; import type {IoDeps, CreateIoDependencies} from './io_dependencies_types'; -import {createIoDependencies} from './io_dependencies'; +import {createIoDependencies as defaultCreateIoDependencies} from './io_dependencies'; + +// Wrapper object to allow mutation for testing +const ioDepsConfig = { + createIoDependencies: defaultCreateIoDependencies +}; export const setIoDependencyCreator = (func: CreateIoDependencies) => { // This is used for testing to override the platform-specific implementation - (createIoDependencies as any) = func; + ioDepsConfig.createIoDependencies = func; }; type IoState = { @@ -73,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/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/vite-plugin/src/plugins/dev.ts b/packages/springboard/vite-plugin/src/plugins/dev.ts index 5a32718a..730147dc 100644 --- a/packages/springboard/vite-plugin/src/plugins/dev.ts +++ b/packages/springboard/vite-plugin/src/plugins/dev.ts @@ -244,7 +244,7 @@ export function springboardDev(options: NormalizedOptions): Plugin { // 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 && + const isUserCode = hasNode && file.startsWith(options.root) && !file.includes(path.sep + 'node_modules' + path.sep) && !file.includes(path.sep + '.springboard' + path.sep) && diff --git a/scripts/run-all-folders.sh b/scripts/run-all-folders.sh index 9b86e988..90fd7c4d 100755 --- a/scripts/run-all-folders.sh +++ b/scripts/run-all-folders.sh @@ -62,6 +62,7 @@ publish_package() { environment="npm" else export NPM_CONFIG_REGISTRY="http://localhost:4873" + # export NPM_CONFIG_REGISTRY="http://10.0.1.1:4873" environment="local Verdaccio" fi diff --git a/verdaccio/docker-compose.yml b/verdaccio/docker-compose.yml index 5a265898..077cae28 100644 --- a/verdaccio/docker-compose.yml +++ b/verdaccio/docker-compose.yml @@ -4,7 +4,7 @@ services: verdaccio: image: verdaccio/verdaccio container_name: 'verdaccio' - network_mode: 'host' + # network_mode: 'host' # networks: # - verdaccio-network environment: @@ -17,4 +17,4 @@ services: - './plugins:/verdaccio/plugins' # networks: # verdaccio-network: -# external: true +# external: true \ No newline at end of file diff --git a/verdaccio/run.sh b/verdaccio/run.sh new file mode 100755 index 00000000..89693e48 --- /dev/null +++ b/verdaccio/run.sh @@ -0,0 +1,19 @@ +# export BASE_PATH="/var/lib/docker/volumes/dowo0cwkww0swc0kg0wgw44k_vibe-kanban-worktrees/_data/e1e4-copy-jamtools-fe/springboard/verdaccio" +# VERDACCIO_CONFIG_PATH="$BASE_PATH/config" \ +# VERDACCIO_STORAGE_PATH="$BASE_PATH/storage" \ +# VERDACCIO_PLUGINS_PATH="$BASE_PATH/plugins" \ +# docker compose up + +echo "registry=http://localhost:4873/" >> ../.npmrc +echo "//localhost:4873/:_authToken=fake" >> ../.npmrc + +npx verdaccio --config ./config/config.yaml + +# curl -L -o $HOME/bin/jq https://github.com/jqlang/jq/releases/latest/download/jq-linux64 +# chmod +x "$HOME/bin/jq" +# export PATH=$PATH:$HOME/bin + +# export npm_config__authToken=fake +# export npm_config_registry=http://localhost:4873/ +# ./scripts/run-all-folders.sh 0.0.1-dev-jamapp-3 + From 48723f5210c34e9e888aa45d7d4c7a9c6c537d93 Mon Sep 17 00:00:00 2001 From: Vibe Kanban Date: Fri, 27 Feb 2026 05:22:17 +0000 Subject: [PATCH 06/22] Fix @tonejs/midi import to use default import with type import Change from direct named import to default import with type import to avoid ES module compatibility issues. Extract MidiClass from the default export and use it for instantiation. Co-Authored-By: Claude Sonnet 4.5 --- .../modules/midi_files/midi_file_parser/midi_file_parser.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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); }; From 51fe57e83f39de6b5e0ebec75c07bc79ace8826c Mon Sep 17 00:00:00 2001 From: Vibe Kanban Date: Sun, 22 Mar 2026 18:36:23 +0000 Subject: [PATCH 07/22] Fix dev plugin stream and module-graph typings --- .../vite-plugin/src/plugins/dev.ts | 54 +++++++++---------- 1 file changed, 25 insertions(+), 29 deletions(-) diff --git a/packages/springboard/vite-plugin/src/plugins/dev.ts b/packages/springboard/vite-plugin/src/plugins/dev.ts index 467f9a33..bfd7dbe8 100644 --- a/packages/springboard/vite-plugin/src/plugins/dev.ts +++ b/packages/springboard/vite-plugin/src/plugins/dev.ts @@ -12,31 +12,15 @@ import * as path from 'path'; import { fileURLToPath } from 'url'; import type { IncomingMessage, ServerResponse } from 'node:http'; import { Readable, type Duplex } from 'node:stream'; +import type { ReadableStream as NodeReadableStream } from 'node:stream/web'; const FALLBACK_HEADER = 'x-springboard-fallback'; -// Vite 6+ types (not available in Vite 5 types but available at runtime with Vite 6+) type ModuleRunner = { import: (url: string) => Promise; close: () => void; }; -type ModuleGraph = { - getModuleById: (id: string) => { id: string } | null; - getModuleByUrl?: (url: string) => { id: string } | null; - invalidateModule: (mod: { id: string }) => void; -}; - -type ViteEnvironments = { - ssr: { - moduleGraph: ModuleGraph; - }; -}; - -type ViteDevServerWithEnvironments = ViteDevServer & { - environments: ViteEnvironments; -}; - type WsAdapter = { handleUpgrade: (req: IncomingMessage, socket: Duplex, head: Buffer) => Promise; closeAll: (code?: number, data?: string | Buffer, force?: boolean) => void; @@ -52,6 +36,10 @@ type DevServerModule = { createDevServer: () => Promise | DevServerHandle; }; +type RequestInitWithDuplex = RequestInit & { + duplex?: 'half'; +}; + /** * Load the node dev entry template from the templates directory */ @@ -86,12 +74,14 @@ const createRequestFromNode = (req: IncomingMessage): Request => { const method = req.method ?? 'GET'; const hasBody = method !== 'GET' && method !== 'HEAD'; if (hasBody) { - return new Request(url, { + const requestBody = Readable.toWeb(req) as unknown as BodyInit; + const requestInit: RequestInitWithDuplex = { method, headers, - body: Readable.toWeb(req), + body: requestBody, duplex: 'half', - }); + }; + return new Request(url, requestInit); } return new Request(url, { method, headers }); @@ -123,7 +113,8 @@ const applyResponseToNode = async (res: ServerResponse, response: Response, meth return; } - const body = Readable.fromWeb(response.body); + const webStream = response.body as unknown as NodeReadableStream; + const body = Readable.fromWeb(webStream); body.on('error', (err) => { res.destroy(err); }); @@ -135,7 +126,7 @@ const applyResponseToNode = async (res: ServerResponse, response: Response, meth */ export function springboardDev(options: NormalizedOptions): Plugin { const logger = createLogger('dev', options.debug); - let server: ViteDevServerWithEnvironments | null = null; + let server: ViteDevServer | null = null; let runner: ModuleRunner | null = null; let currentFetch: ((request: Request) => Promise) | null = null; let currentWs: WsAdapter | null = null; @@ -172,7 +163,7 @@ export function springboardDev(options: NormalizedOptions): Plugin { * Configure the dev server */ async configureServer(devServer: ViteDevServer) { - server = devServer as ViteDevServerWithEnvironments; + server = devServer; logger.info(`Dev server starting for platform: ${options.platform}`); @@ -224,7 +215,7 @@ export function springboardDev(options: NormalizedOptions): Plugin { const startServer = async () => { const viteModule = await import('vite') as unknown as { - createServerModuleRunner: (env: ViteEnvironments['ssr']) => ModuleRunner; + createServerModuleRunner: (env: ViteDevServer['environments']['ssr']) => ModuleRunner; }; runner = viteModule.createServerModuleRunner(server!.environments.ssr); @@ -301,14 +292,19 @@ export function springboardDev(options: NormalizedOptions): Plugin { }); devServer.watcher.on('change', (file) => { - const ssrModule = server?.environments.ssr.moduleGraph.getModuleById(file) - ?? server?.environments.ssr.moduleGraph.getModuleByUrl?.(file) - ?? null; - if (!ssrModule) { + const ssrModuleGraph = server?.environments.ssr.moduleGraph; + if (!ssrModuleGraph) { + return; + } + + const changedModules = ssrModuleGraph.getModulesByFile(file); + if (!changedModules || changedModules.size === 0) { return; } - server?.environments.ssr.moduleGraph.invalidateModule(ssrModule); + for (const moduleNode of changedModules) { + ssrModuleGraph.invalidateModule(moduleNode); + } void reloadServer(); }); }, From c6b22d8e2f483dba9c5bde68a5d9042828b5646f Mon Sep 17 00:00:00 2001 From: Vibe Kanban Date: Sun, 22 Mar 2026 18:50:31 +0000 Subject: [PATCH 08/22] Add e2e dev-route HMR test for registerServerModule --- package.json | 2 +- tests/e2e/vite-dev-server-route-hmr.test.ts | 184 ++++++++++++++++++++ 2 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/vite-dev-server-route-hmr.test.ts 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/tests/e2e/vite-dev-server-route-hmr.test.ts b/tests/e2e/vite-dev-server-route-hmr.test.ts new file mode 100644 index 00000000..ae6a353c --- /dev/null +++ b/tests/e2e/vite-dev-server-route-hmr.test.ts @@ -0,0 +1,184 @@ +import { afterEach, describe, expect, test } from 'vitest'; +import { createServer, type ViteDevServer } from 'vite'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +import { springboardDev } from '../../packages/springboard/vite-plugin/src/plugins/dev.js'; +import type { NormalizedOptions } from '../../packages/springboard/vite-plugin/src/types.js'; + +const TEST_ROUTE_PATH = '/__e2e/custom-route'; +const INITIAL_ROUTE_VALUE = 'ROUTE_VERSION_1'; +const UPDATED_ROUTE_VALUE = 'ROUTE_VERSION_2'; +const ROUTE_WAIT_TIMEOUT_MS = 30_000; +const ROUTE_POLL_INTERVAL_MS = 150; +const TMP_PARENT_DIR = path.resolve(process.cwd(), 'packages/springboard/.tmp-e2e-fixtures'); + +type RunningFixture = { + fixtureRoot: string; + routeFilePath: string; + server: ViteDevServer; + port: number; +}; + +const delay = async (ms: number): Promise => { + await new Promise((resolve) => setTimeout(resolve, ms)); +}; + +const createFixture = async (): Promise<{ fixtureRoot: string; routeFilePath: string }> => { + await fs.mkdir(TMP_PARENT_DIR, { recursive: true }); + const fixtureRoot = await fs.mkdtemp(path.join(TMP_PARENT_DIR, 'vite-dev-route-')); + const sourceDir = path.join(fixtureRoot, 'src'); + const routeFilePath = path.join(sourceDir, 'server-entry.ts'); + const browserEntryPath = path.join(sourceDir, 'browser-entry.ts'); + const indexHtmlPath = path.join(fixtureRoot, 'index.html'); + + await fs.mkdir(sourceDir, { recursive: true }); + + await fs.writeFile(routeFilePath, ` +import { serverRegistry } from 'springboard/server/register'; + +serverRegistry.registerServerModule((server) => { + server.hono.get('${TEST_ROUTE_PATH}', (c) => { + return c.text('${INITIAL_ROUTE_VALUE}'); + }); +}); +`.trimStart(), 'utf-8'); + + await fs.writeFile(browserEntryPath, 'export {};\n', 'utf-8'); + await fs.writeFile(indexHtmlPath, '
\n', 'utf-8'); + + return { fixtureRoot, routeFilePath }; +}; + +const makeOptions = (fixtureRoot: string): NormalizedOptions => { + return { + entry: './src/server-entry.ts', + entryConfig: { + node: './src/server-entry.ts', + browser: './src/browser-entry.ts', + }, + platforms: ['node'], + platform: 'node', + platformMacro: 'node', + documentMeta: undefined, + viteConfig: undefined, + debug: false, + partykitName: undefined, + outDir: 'dist', + root: fixtureRoot, + nodeServerPort: 1337, + }; +}; + +const getListeningPort = (server: ViteDevServer): number => { + const address = server.httpServer?.address(); + if (!address || typeof address === 'string') { + throw new Error('Failed to read dev server address'); + } + return address.port; +}; + +const startFixtureServer = async (fixtureRoot: string): Promise<{ server: ViteDevServer; port: number }> => { + const server = await createServer({ + configFile: false, + root: fixtureRoot, + logLevel: 'error', + server: { + host: '127.0.0.1', + port: 0, + }, + plugins: [springboardDev(makeOptions(fixtureRoot))], + }); + + await server.listen(); + const port = getListeningPort(server); + return { server, port }; +}; + +const getRouteResponse = async (port: number): Promise<{ status: number; body: string }> => { + const url = `http://127.0.0.1:${port}${TEST_ROUTE_PATH}`; + const response = await fetch(url, { cache: 'no-store' }); + const body = await response.text(); + return { status: response.status, body }; +}; + +const waitForRouteValue = async (port: number, expectedBody: string): Promise => { + const deadline = Date.now() + ROUTE_WAIT_TIMEOUT_MS; + let lastSeen = ''; + + while (Date.now() < deadline) { + try { + const { status, body } = await getRouteResponse(port); + lastSeen = `status=${status} body=${body}`; + if (status === 200 && body === expectedBody) { + return; + } + } catch (error) { + if (error instanceof Error) { + lastSeen = error.message; + } else { + lastSeen = String(error); + } + } + + await delay(ROUTE_POLL_INTERVAL_MS); + } + + throw new Error(`Timed out waiting for route body "${expectedBody}". Last seen: ${lastSeen}`); +}; + +const editRouteSource = async (routeFilePath: string): Promise => { + const before = await fs.readFile(routeFilePath, 'utf-8'); + if (!before.includes(INITIAL_ROUTE_VALUE)) { + throw new Error(`Expected source to contain ${INITIAL_ROUTE_VALUE}`); + } + + const after = before.replace(INITIAL_ROUTE_VALUE, UPDATED_ROUTE_VALUE); + await fs.writeFile(routeFilePath, after, 'utf-8'); +}; + +describe('Springboard dev server route HMR', () => { + let runningFixture: RunningFixture | null = null; + + afterEach(async () => { + if (runningFixture) { + await runningFixture.server.close(); + await fs.rm(runningFixture.fixtureRoot, { recursive: true, force: true }); + runningFixture = null; + } + + await fs.rm(TMP_PARENT_DIR, { recursive: true, force: true }); + }); + + test('reloads registerServerModule route after source edit', async () => { + const { fixtureRoot, routeFilePath } = await createFixture(); + try { + const { server, port } = await startFixtureServer(fixtureRoot); + runningFixture = { + fixtureRoot, + routeFilePath, + server, + port, + }; + } catch (error) { + await fs.rm(fixtureRoot, { recursive: true, force: true }); + throw error; + } + + if (!runningFixture) { + throw new Error('Fixture server failed to start'); + } + + const { port } = runningFixture; + + await waitForRouteValue(port, INITIAL_ROUTE_VALUE); + + await editRouteSource(routeFilePath); + + await waitForRouteValue(port, UPDATED_ROUTE_VALUE); + + const finalResponse = await getRouteResponse(port); + expect(finalResponse.status).toBe(200); + expect(finalResponse.body).toBe(UPDATED_ROUTE_VALUE); + }); +}); From ac6c546489ca4dc1345fd9939dbd590d52cb62d2 Mon Sep 17 00:00:00 2001 From: Vibe Kanban Date: Wed, 25 Mar 2026 01:33:50 +0000 Subject: [PATCH 09/22] docs: clarify registerServerModule contract --- packages/springboard/src/server/register.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/springboard/src/server/register.ts b/packages/springboard/src/server/register.ts index f70d979e..8025c87e 100644 --- a/packages/springboard/src/server/register.ts +++ b/packages/springboard/src/server/register.ts @@ -34,6 +34,14 @@ const registerServerModule = ( }; 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; From 76d916a7aaec21d78dac58f181f1aa42179053c0 Mon Sep 17 00:00:00 2001 From: Vibe Kanban Date: Wed, 25 Mar 2026 01:45:00 +0000 Subject: [PATCH 10/22] apps/vite-test: add start script and configurable port --- apps/vite-test/package.json | 3 ++- apps/vite-test/vite.config.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/vite-test/package.json b/apps/vite-test/package.json index bd5f296f..e5982d8f 100644 --- a/apps/vite-test/package.json +++ b/apps/vite-test/package.json @@ -12,7 +12,8 @@ "build:web": "SPRINGBOARD_PLATFORM=web vite build", "build:node": "SPRINGBOARD_PLATFORM=node vite build --outDir dist/node", "dev": "vite", - "check-types": "tsc --noEmit" + "check-types": "tsc --noEmit", + "start": "node dist/node/node-entry.mjs" }, "engines": { "node": ">=20.0.0" 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: { From b2a694f118387c1ec8e0229c215cd00c43d81ba2 Mon Sep 17 00:00:00 2001 From: Vibe Kanban Date: Wed, 25 Mar 2026 01:45:05 +0000 Subject: [PATCH 11/22] Committed the two requested files as `apps/vite-test: add start script and configurable port` in `76d916a`. I left [`apps/vite-test/src/tic_tac_toe.tsx`](/var/tmp/vibe-kanban/worktrees/8100-fix-vite-dev-nod/springboard/apps/vite-test/src/tic_tac_toe.tsx) unstaged, since it was modified but not included in your list. --- apps/vite-test/src/tic_tac_toe.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/vite-test/src/tic_tac_toe.tsx b/apps/vite-test/src/tic_tac_toe.tsx index ba58aa0f..50e0fe68 100644 --- a/apps/vite-test/src/tic_tac_toe.tsx +++ b/apps/vite-test/src/tic_tac_toe.tsx @@ -60,6 +60,7 @@ const checkForWinner = (board: Board): Winner => { }; springboard.registerModule('TicTacToe', {}, async (moduleAPI) => { + console.log('yeah') 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}); From 1543cb524b4b80aca674680bcd167374a6e2921a Mon Sep 17 00:00:00 2001 From: Vibe Kanban Date: Wed, 25 Mar 2026 01:48:53 +0000 Subject: [PATCH 12/22] apps/vite-test: add debug log for module load --- apps/vite-test/src/tic_tac_toe.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/vite-test/src/tic_tac_toe.tsx b/apps/vite-test/src/tic_tac_toe.tsx index 50e0fe68..2fc6520a 100644 --- a/apps/vite-test/src/tic_tac_toe.tsx +++ b/apps/vite-test/src/tic_tac_toe.tsx @@ -59,6 +59,8 @@ const checkForWinner = (board: Board): Winner => { return null; }; +console.log('yeah2') + springboard.registerModule('TicTacToe', {}, async (moduleAPI) => { console.log('yeah') const boardState = await moduleAPI.statesAPI.createPersistentState('board_v5', initialBoard); From 2335c8e0de964b2d3362bcc4ba25d949a545f7f7 Mon Sep 17 00:00:00 2001 From: Vibe Kanban Date: Wed, 25 Mar 2026 02:00:28 +0000 Subject: [PATCH 13/22] fix: use single-port dev server in vite plugin --- packages/springboard/vite-plugin/src/index.ts | 320 ++++++++++++------ .../vite-plugin/src/plugins/dev.ts | 4 + .../src/templates/node-dev-entry.template.ts | 14 +- 3 files changed, 228 insertions(+), 110 deletions(-) diff --git a/packages/springboard/vite-plugin/src/index.ts b/packages/springboard/vite-plugin/src/index.ts index 15c5371c..3d0b09ba 100644 --- a/packages/springboard/vite-plugin/src/index.ts +++ b/packages/springboard/vite-plugin/src/index.ts @@ -27,11 +27,14 @@ import { PluginOption, ViteDevServer } from 'vite'; import * as path from 'path'; import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'; import { fileURLToPath } from 'url'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { Readable, type Duplex } from 'node:stream'; +import type { ReadableStream as NodeReadableStream } from 'node:stream/web'; import { applyPlatformTransform } from './plugins/platform-inject.js'; // Vite 6+ types (ModuleRunner not exported from vite types but available at runtime) type ModuleRunner = { - import: (url: string) => Promise; + import: (url: string) => Promise; close: () => void; }; @@ -43,8 +46,92 @@ type ViteDevServerWithEnvironments = ViteDevServer & { environments: ViteEnvironments; }; +type WsAdapter = { + handleUpgrade: (req: IncomingMessage, socket: Duplex, head: Buffer) => Promise; + closeAll: (code?: number, data?: string | Buffer, force?: boolean) => void; +}; + +type DevServerHandle = { + fetch: (request: Request) => Promise; + ws?: WsAdapter; + dispose?: () => Promise | void; +}; + +type DevServerModule = { + createDevServer: () => Promise | DevServerHandle; +}; + +type RequestInitWithDuplex = RequestInit & { + duplex?: 'half'; +}; + type PlatformKey = 'node' | 'browser' | 'web'; +const FALLBACK_HEADER = 'x-springboard-fallback'; + +const createRequestFromNode = (req: IncomingMessage): Request => { + const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`); + const headers = new Headers(); + + for (const [key, value] of Object.entries(req.headers)) { + if (typeof value === 'string') { + headers.set(key, value); + } else if (Array.isArray(value)) { + for (const entry of value) { + headers.append(key, entry); + } + } + } + + const method = req.method ?? 'GET'; + if (method !== 'GET' && method !== 'HEAD') { + const requestBody = Readable.toWeb(req) as unknown as BodyInit; + const requestInit: RequestInitWithDuplex = { + method, + headers, + body: requestBody, + duplex: 'half', + }; + return new Request(url, requestInit); + } + + return new Request(url, { method, headers }); +}; + +const applyResponseToNode = async (res: ServerResponse, response: Response, method: string): Promise => { + res.statusCode = response.status; + if (response.statusText) { + res.statusMessage = response.statusText; + } + + const headers = response.headers; + for (const [key, value] of headers.entries()) { + if (key.toLowerCase() === 'set-cookie') { + continue; + } + res.setHeader(key, value); + } + + const setCookie = 'getSetCookie' in headers + ? (headers as unknown as { getSetCookie: () => string[] }).getSetCookie() + : []; + if (setCookie.length > 0) { + res.setHeader('set-cookie', setCookie); + } + + if (method === 'HEAD' || response.body === null) { + res.end(); + return; + } + + const webStream = response.body as unknown as NodeReadableStream; + const body = Readable.fromWeb(webStream); + body.on('error', (err) => { + res.destroy(err); + }); + body.pipe(res); +}; + export type SpringboardOptions = { entry: string | Record; documentMeta?: Record; @@ -104,6 +191,7 @@ export function springboard(options: SpringboardOptions): PluginOption { const WEB_ENTRY_FILE = path.join(SPRINGBOARD_DIR, 'web-entry.js'); const WEB_HTML_FILE = path.join(projectRoot, 'index.html'); // At project root for Vite const NODE_ENTRY_FILE = path.join(SPRINGBOARD_DIR, 'node-entry.ts'); + const NODE_DEV_ENTRY_FILE = path.join(SPRINGBOARD_DIR, 'node-dev-entry.ts'); // Load HTML template const htmlTemplate = readFileSync( @@ -131,6 +219,10 @@ export function springboard(options: SpringboardOptions): PluginOption { path.join(templatesDir, 'node-entry.template.ts'), 'utf-8' ); + const nodeDevEntryTemplate = readFileSync( + path.join(templatesDir, 'node-dev-entry.template.ts'), + 'utf-8' + ); return { name: 'springboard', @@ -187,32 +279,11 @@ export function springboard(options: SpringboardOptions): PluginOption { // Set dev mode flag based on Vite's command isDevMode = env.command === 'serve'; - // Dev mode with both platforms - configure Vite proxy and SSR - if (isDevMode && hasNode && hasWeb) { - const nodePort = options.nodeServerPort ?? 1337; - + if (isDevMode && hasNode) { return { server: { - proxy: { - '/rpc': { - target: `http://localhost:${nodePort}`, - changeOrigin: true, - }, - '/kv': { - target: `http://localhost:${nodePort}`, - changeOrigin: true, - }, - '/ws': { - target: `ws://localhost:${nodePort}`, - ws: true, - changeOrigin: true, - }, - }, - }, - build: { - rollupOptions: { - input: WEB_ENTRY_FILE, // Browser entry - } + perEnvironmentStartEndDuringDev: true, + perEnvironmentWatchChangeDuringDev: true, }, ssr: { // External dependencies for SSR (node modules that shouldn't be bundled) @@ -262,8 +333,7 @@ export function springboard(options: SpringboardOptions): PluginOption { }, configureServer(server: ViteDevServer) { - // First, add HTML serving middleware - return () => { + return async () => { // Serve HTML for / and /index.html server.middlewares.use((req, res, next) => { if (req.url === '/' || req.url === '/index.html') { @@ -278,100 +348,144 @@ export function springboard(options: SpringboardOptions): PluginOption { next(); }); - // Only spawn node server if hasNode is true if (!hasNode) { console.log('[springboard] Browser-only mode - not starting node server'); return; } - // Generate node entry file for dev mode - if (!existsSync(SPRINGBOARD_DIR)) { - mkdirSync(SPRINGBOARD_DIR, { recursive: true }); - } + if (!existsSync(SPRINGBOARD_DIR)) { + mkdirSync(SPRINGBOARD_DIR, { recursive: true }); + } - // Calculate the correct import path from .springboard/ to the user's entry file - const platformEntry = resolveEntry('node'); - const absoluteEntryPath = path.isAbsolute(platformEntry) - ? platformEntry - : path.resolve(projectRoot, platformEntry); - const relativeEntryPath = path.relative(SPRINGBOARD_DIR, absoluteEntryPath); + // Calculate the correct import path from .springboard/ to the user's entry file + const platformEntry = resolveEntry('node'); + const absoluteEntryPath = path.isAbsolute(platformEntry) + ? platformEntry + : path.resolve(projectRoot, platformEntry); + const relativeEntryPath = path.relative(SPRINGBOARD_DIR, absoluteEntryPath); + + const nodeDevEntryCode = nodeDevEntryTemplate.replace('__USER_ENTRY__', relativeEntryPath); + writeFileSync(NODE_DEV_ENTRY_FILE, nodeDevEntryCode, 'utf-8'); + console.log('[springboard] Generated node dev entry file for single-port mode'); + + let runner: ModuleRunner | null = null; + let currentFetch: ((request: Request) => Promise) | null = null; + let currentWs: WsAdapter | null = null; + let currentDispose: (() => Promise | void) | null = null; + let reloadInFlight: Promise | null = null; + + const stopDevServer = async () => { + if (currentDispose) { + await currentDispose(); + } + if (currentWs) { + currentWs.closeAll(); + } + currentFetch = null; + currentWs = null; + currentDispose = null; + if (runner) { + runner.close(); + runner = null; + } + }; + + const startDevServer = async () => { + try { + const viteModule = await import('vite') as unknown as { + createServerModuleRunner: (env: ViteDevServerWithEnvironments['environments']['ssr']) => ModuleRunner; + }; + + const serverWithEnv = server as ViteDevServerWithEnvironments; + runner = viteModule.createServerModuleRunner(serverWithEnv.environments.ssr); - const port = options.nodeServerPort ?? 1337; - const nodeEntryCode = nodeEntryTemplate - .replace('__USER_ENTRY__', relativeEntryPath) - .replace('__PORT__', String(port)); - writeFileSync(NODE_ENTRY_FILE, nodeEntryCode, 'utf-8'); - console.log('[springboard] Generated node entry file for dev mode'); - - let runner: ModuleRunner | null = null; - let nodeEntryModule: { start?: () => Promise; stop?: () => Promise } | null = null; - - // Start the node server using Vite 6+ ModuleRunner API - const startNodeServer = async () => { - try { - // Dynamically import createServerModuleRunner (Vite 6+ API) - // Type assertion needed because we're building with Vite 5 types but running with Vite 6+ - const viteModule = await import('vite') as unknown as { - createServerModuleRunner: (env: unknown) => ModuleRunner; - }; - - // Create module runner with HMR support - const serverWithEnv = server as ViteDevServerWithEnvironments; - runner = viteModule.createServerModuleRunner(serverWithEnv.environments.ssr); - - // Load and execute the node entry module - nodeEntryModule = await runner.import(NODE_ENTRY_FILE); - - // Call the exported start() function - if (nodeEntryModule && typeof nodeEntryModule.start === 'function') { - await nodeEntryModule.start(); - console.log('[springboard] Node server started via ModuleRunner'); - } else { - console.error('[springboard] Node entry does not export a start() function'); + const mod = await runner.import(NODE_DEV_ENTRY_FILE); + if (!mod || typeof mod.createDevServer !== 'function') { + console.error('[springboard] Node dev entry does not export createDevServer()'); + return; + } + + const handle = await mod.createDevServer(); + currentFetch = handle.fetch; + currentWs = handle.ws ?? null; + currentDispose = handle.dispose ?? null; + } catch (err) { + console.error('[springboard] Failed to start node dev server:', err); } - } catch (err) { - console.error('[springboard] Failed to start node server:', err); - } - }; + }; + + const reloadDevServer = async () => { + if (reloadInFlight) { + return reloadInFlight; + } + + reloadInFlight = (async () => { + await stopDevServer(); + await startDevServer(); + })(); - const stopNodeServer = async () => { - if (runner) { try { - // First, manually call stop() on the node entry module to close the HTTP server - // This is necessary because when Vite restarts (e.g., config change), - // the HMR dispose handler doesn't get called - if (nodeEntryModule?.stop && typeof nodeEntryModule.stop === 'function') { - await nodeEntryModule.stop(); - console.log('[springboard] Node server stopped manually'); + await reloadInFlight; + } finally { + reloadInFlight = null; + } + + return undefined; + }; + + await startDevServer(); + + server.middlewares.use(async (req, res, next) => { + if (!currentFetch) { + next(); + return; + } + + try { + const request = createRequestFromNode(req); + const response = await currentFetch(request); + + if (response.headers.get(FALLBACK_HEADER) === '1') { + next(); + return; } - // Then close the runner (renamed from destroy() in Vite 6+) - runner.close(); - runner = null; - nodeEntryModule = null; - console.log('[springboard] Node server runner closed'); + await applyResponseToNode(res, response, request.method); } catch (err) { - console.error('[springboard] Failed to stop node server:', err); + next(err as Error); + } + }); + + server.httpServer?.on('upgrade', (req, socket, head) => { + if (!currentWs) { + return; } - } - }; - // Start the node server when Vite dev server starts - startNodeServer(); + const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`); + if (url.pathname !== '/ws') { + return; + } + + void currentWs.handleUpgrade(req, socket, head); + }); - console.log('[springboard] Vite proxy configured via server.proxy:'); - console.log(`[springboard] /rpc/* -> http://localhost:${port}/rpc/*`); - console.log(`[springboard] /kv/* -> http://localhost:${port}/kv/*`); - console.log(`[springboard] /ws -> ws://localhost:${port}/ws (WebSocket)`); + // Clean up when Vite dev server closes + server.httpServer?.on('close', () => { + void stopDevServer(); + }); - // Clean up when Vite dev server closes - server.httpServer?.on('close', () => { - stopNodeServer(); - }); + server.watcher.on('change', (file) => { + const ssrModuleGraph = server.environments.ssr.moduleGraph; + const changedModules = ssrModuleGraph.getModulesByFile(file); + if (!changedModules || changedModules.size === 0) { + return; + } - // Note: We DON'T add our own SIGINT/SIGTERM handlers here - // because Vite already handles those and will trigger the 'close' event - // Adding our own handlers would interfere with Vite's shutdown process + for (const moduleNode of changedModules) { + ssrModuleGraph.invalidateModule(moduleNode); + } + void reloadDevServer(); + }); }; }, diff --git a/packages/springboard/vite-plugin/src/plugins/dev.ts b/packages/springboard/vite-plugin/src/plugins/dev.ts index bfd7dbe8..90bacb51 100644 --- a/packages/springboard/vite-plugin/src/plugins/dev.ts +++ b/packages/springboard/vite-plugin/src/plugins/dev.ts @@ -147,6 +147,10 @@ export function springboardDev(options: NormalizedOptions): Plugin { config() { if (hasNode || hasBrowser) { return { + server: { + perEnvironmentStartEndDuringDev: true, + perEnvironmentWatchChangeDuringDev: true, + }, ssr: { // noExternal fixes missing .js extensions in springboard imports noExternal: ['springboard'], diff --git a/packages/springboard/vite-plugin/src/templates/node-dev-entry.template.ts b/packages/springboard/vite-plugin/src/templates/node-dev-entry.template.ts index 0e04f5c7..928cff22 100644 --- a/packages/springboard/vite-plugin/src/templates/node-dev-entry.template.ts +++ b/packages/springboard/vite-plugin/src/templates/node-dev-entry.template.ts @@ -20,15 +20,15 @@ export type DevServerHandle = { dispose: () => Promise; }; -export async function createDevServer(): Promise { - springboard.reset(); - clearRegisteredModules(); - clearRegisteredClassModules(); - clearRegisteredSplashScreen(); - resetServerRegistry(); +springboard.reset(); +clearRegisteredModules(); +clearRegisteredClassModules(); +clearRegisteredSplashScreen(); +resetServerRegistry(); - await import('__USER_ENTRY__'); +await import('__USER_ENTRY__'); +export async function createDevServer(): Promise { const nodeKvDeps = await makeWebsocketServerCoreDependenciesWithSqlite(); const useWebSocketsForRpc = import.meta.env.VITE_USE_WEBSOCKETS_FOR_RPC === 'true'; From b3b962883031b0dba4df00298ff1dbf3f81276f4 Mon Sep 17 00:00:00 2001 From: Vibe Kanban Date: Wed, 25 Mar 2026 16:13:51 +0000 Subject: [PATCH 14/22] fix: install vite dev middleware before spa fallback --- apps/vite-test/package.json | 1 + apps/vite-test/src/tic_tac_toe.tsx | 10 +- packages/springboard/vite-plugin/src/index.ts | 208 +++++++++--------- 3 files changed, 112 insertions(+), 107 deletions(-) diff --git a/apps/vite-test/package.json b/apps/vite-test/package.json index e5982d8f..a4bf680a 100644 --- a/apps/vite-test/package.json +++ b/apps/vite-test/package.json @@ -9,6 +9,7 @@ "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 -- --host 0.0.0.0", "build:web": "SPRINGBOARD_PLATFORM=web vite build", "build:node": "SPRINGBOARD_PLATFORM=node vite build --outDir dist/node", "dev": "vite", diff --git a/apps/vite-test/src/tic_tac_toe.tsx b/apps/vite-test/src/tic_tac_toe.tsx index 2fc6520a..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" @@ -59,10 +66,7 @@ const checkForWinner = (board: Board): Winner => { return null; }; -console.log('yeah2') - springboard.registerModule('TicTacToe', {}, async (moduleAPI) => { - console.log('yeah') 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}); diff --git a/packages/springboard/vite-plugin/src/index.ts b/packages/springboard/vite-plugin/src/index.ts index 3d0b09ba..a6770f9b 100644 --- a/packages/springboard/vite-plugin/src/index.ts +++ b/packages/springboard/vite-plugin/src/index.ts @@ -333,6 +333,110 @@ export function springboard(options: SpringboardOptions): PluginOption { }, configureServer(server: ViteDevServer) { + let runner: ModuleRunner | null = null; + let currentFetch: ((request: Request) => Promise) | null = null; + let currentWs: WsAdapter | null = null; + let currentDispose: (() => Promise | void) | null = null; + let reloadInFlight: Promise | null = null; + + const stopDevServer = async () => { + if (currentDispose) { + await currentDispose(); + } + if (currentWs) { + currentWs.closeAll(); + } + currentFetch = null; + currentWs = null; + currentDispose = null; + if (runner) { + runner.close(); + runner = null; + } + }; + + const startDevServer = async () => { + try { + const viteModule = await import('vite') as unknown as { + createServerModuleRunner: (env: ViteDevServerWithEnvironments['environments']['ssr']) => ModuleRunner; + }; + + const serverWithEnv = server as ViteDevServerWithEnvironments; + runner = viteModule.createServerModuleRunner(serverWithEnv.environments.ssr); + + const mod = await runner.import(NODE_DEV_ENTRY_FILE); + if (!mod || typeof mod.createDevServer !== 'function') { + console.error('[springboard] Node dev entry does not export createDevServer()'); + return; + } + + const handle = await mod.createDevServer(); + currentFetch = handle.fetch; + currentWs = handle.ws ?? null; + currentDispose = handle.dispose ?? null; + } catch (err) { + console.error('[springboard] Failed to start node dev server:', err); + } + }; + + const reloadDevServer = async () => { + if (reloadInFlight) { + return reloadInFlight; + } + + reloadInFlight = (async () => { + await stopDevServer(); + await startDevServer(); + })(); + + try { + await reloadInFlight; + } finally { + reloadInFlight = null; + } + + return undefined; + }; + + server.middlewares.use(async (req, res, next) => { + if (!currentFetch) { + next(); + return; + } + + try { + const request = createRequestFromNode(req); + const response = await currentFetch(request); + + if (response.headers.get(FALLBACK_HEADER) === '1') { + next(); + return; + } + + await applyResponseToNode(res, response, request.method); + } catch (err) { + next(err as Error); + } + }); + + server.httpServer?.on('upgrade', (req, socket, head) => { + if (!currentWs) { + return; + } + + const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`); + if (url.pathname !== '/ws') { + return; + } + + void currentWs.handleUpgrade(req, socket, head); + }); + + // Clean up when Vite dev server closes + server.httpServer?.on('close', () => { + void stopDevServer(); + }); + return async () => { // Serve HTML for / and /index.html server.middlewares.use((req, res, next) => { @@ -368,112 +472,8 @@ export function springboard(options: SpringboardOptions): PluginOption { writeFileSync(NODE_DEV_ENTRY_FILE, nodeDevEntryCode, 'utf-8'); console.log('[springboard] Generated node dev entry file for single-port mode'); - let runner: ModuleRunner | null = null; - let currentFetch: ((request: Request) => Promise) | null = null; - let currentWs: WsAdapter | null = null; - let currentDispose: (() => Promise | void) | null = null; - let reloadInFlight: Promise | null = null; - - const stopDevServer = async () => { - if (currentDispose) { - await currentDispose(); - } - if (currentWs) { - currentWs.closeAll(); - } - currentFetch = null; - currentWs = null; - currentDispose = null; - if (runner) { - runner.close(); - runner = null; - } - }; - - const startDevServer = async () => { - try { - const viteModule = await import('vite') as unknown as { - createServerModuleRunner: (env: ViteDevServerWithEnvironments['environments']['ssr']) => ModuleRunner; - }; - - const serverWithEnv = server as ViteDevServerWithEnvironments; - runner = viteModule.createServerModuleRunner(serverWithEnv.environments.ssr); - - const mod = await runner.import(NODE_DEV_ENTRY_FILE); - if (!mod || typeof mod.createDevServer !== 'function') { - console.error('[springboard] Node dev entry does not export createDevServer()'); - return; - } - - const handle = await mod.createDevServer(); - currentFetch = handle.fetch; - currentWs = handle.ws ?? null; - currentDispose = handle.dispose ?? null; - } catch (err) { - console.error('[springboard] Failed to start node dev server:', err); - } - }; - - const reloadDevServer = async () => { - if (reloadInFlight) { - return reloadInFlight; - } - - reloadInFlight = (async () => { - await stopDevServer(); - await startDevServer(); - })(); - - try { - await reloadInFlight; - } finally { - reloadInFlight = null; - } - - return undefined; - }; - await startDevServer(); - server.middlewares.use(async (req, res, next) => { - if (!currentFetch) { - next(); - return; - } - - try { - const request = createRequestFromNode(req); - const response = await currentFetch(request); - - if (response.headers.get(FALLBACK_HEADER) === '1') { - next(); - return; - } - - await applyResponseToNode(res, response, request.method); - } catch (err) { - next(err as Error); - } - }); - - server.httpServer?.on('upgrade', (req, socket, head) => { - if (!currentWs) { - return; - } - - const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`); - if (url.pathname !== '/ws') { - return; - } - - void currentWs.handleUpgrade(req, socket, head); - }); - - // Clean up when Vite dev server closes - server.httpServer?.on('close', () => { - void stopDevServer(); - }); - server.watcher.on('change', (file) => { const ssrModuleGraph = server.environments.ssr.moduleGraph; const changedModules = ssrModuleGraph.getModulesByFile(file); From 6a8c1c4bd8c4cc0dde2e0e6d895dbf7b0c196740 Mon Sep 17 00:00:00 2001 From: Vibe Kanban Date: Wed, 25 Mar 2026 16:32:31 +0000 Subject: [PATCH 15/22] refactor: remove unused vite plugin modules --- .../vite-plugin/src/config/detect-platform.ts | 107 ------ .../src/config/platform-configs.ts | 323 ------------------ .../vite-plugin/src/plugins/build.ts | 16 - .../vite-plugin/src/plugins/html.ts | 202 ----------- .../vite-plugin/src/plugins/init.ts | 188 ---------- .../vite-plugin/src/plugins/virtual.ts | 85 ----- .../vite-plugin/src/utils/generate-entry.ts | 320 ----------------- .../src/utils/normalize-options.ts | 193 ----------- 8 files changed, 1434 deletions(-) delete mode 100644 packages/springboard/vite-plugin/src/config/detect-platform.ts delete mode 100644 packages/springboard/vite-plugin/src/config/platform-configs.ts delete mode 100644 packages/springboard/vite-plugin/src/plugins/build.ts delete mode 100644 packages/springboard/vite-plugin/src/plugins/html.ts delete mode 100644 packages/springboard/vite-plugin/src/plugins/init.ts delete mode 100644 packages/springboard/vite-plugin/src/plugins/virtual.ts delete mode 100644 packages/springboard/vite-plugin/src/utils/generate-entry.ts delete mode 100644 packages/springboard/vite-plugin/src/utils/normalize-options.ts diff --git a/packages/springboard/vite-plugin/src/config/detect-platform.ts b/packages/springboard/vite-plugin/src/config/detect-platform.ts deleted file mode 100644 index c290e006..00000000 --- a/packages/springboard/vite-plugin/src/config/detect-platform.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * Platform Detection - * - * Detects the current platform based on environment variables and Vite config. - */ - -import type { ConfigEnv } from 'vite'; -import type { Platform, NormalizedOptions } from '../types.js'; - -/** - * Environment variable name for platform override - */ -export const PLATFORM_ENV_VAR = 'SPRINGBOARD_PLATFORM'; - -/** - * Detect the current platform from environment and config. - * - * Priority: - * 1. SPRINGBOARD_PLATFORM environment variable - * 2. Vite's SSR build flag (maps to node) - * 3. First platform in the platforms array - * - * @param env - Vite config environment - * @param platforms - List of target platforms - * @returns Detected platform - */ -export function detectPlatform( - env: ConfigEnv, - platforms: Platform[] -): Platform { - // Check environment variable first - const envPlatform = process.env[PLATFORM_ENV_VAR] as Platform | undefined; - if (envPlatform && isValidPlatform(envPlatform)) { - return envPlatform; - } - - // Check if this is an SSR build (typically means node/server) - if (env.isSsrBuild) { - // Find a server platform in the list - const serverPlatform = platforms.find(p => p === 'node' || p === 'partykit'); - if (serverPlatform) { - return serverPlatform; - } - } - - // Default to first platform - return platforms[0] || 'browser'; -} - -/** - * Check if a string is a valid platform - */ -export function isValidPlatform(platform: string): platform is Platform { - const validPlatforms: Platform[] = [ - 'browser', - 'node', - 'partykit', - 'tauri', - 'react-native', - ]; - return validPlatforms.includes(platform as Platform); -} - -/** - * Get platform from options, with environment override - */ -export function getPlatformFromOptions(options: NormalizedOptions): Platform { - const envPlatform = process.env[PLATFORM_ENV_VAR] as Platform | undefined; - if (envPlatform && isValidPlatform(envPlatform)) { - return envPlatform; - } - return options.platform; -} - -/** - * Set the platform environment variable for child processes - */ -export function setPlatformEnv(platform: Platform): void { - process.env[PLATFORM_ENV_VAR] = platform; -} - -/** - * Clear the platform environment variable - */ -export function clearPlatformEnv(): void { - delete process.env[PLATFORM_ENV_VAR]; -} - -/** - * Run a function with a specific platform set in environment - */ -export async function withPlatform( - platform: Platform, - fn: () => T | Promise -): Promise { - const previousPlatform = process.env[PLATFORM_ENV_VAR]; - setPlatformEnv(platform); - try { - return await fn(); - } finally { - if (previousPlatform) { - process.env[PLATFORM_ENV_VAR] = previousPlatform; - } else { - clearPlatformEnv(); - } - } -} diff --git a/packages/springboard/vite-plugin/src/config/platform-configs.ts b/packages/springboard/vite-plugin/src/config/platform-configs.ts deleted file mode 100644 index 6cf167ed..00000000 --- a/packages/springboard/vite-plugin/src/config/platform-configs.ts +++ /dev/null @@ -1,323 +0,0 @@ -/** - * Platform Configurations - * - * Default Vite configurations for each platform. - * These provide sensible defaults that can be overridden by users. - */ - -import type { UserConfig } from 'vite'; -import type { Platform, NormalizedOptions } from '../types.js'; - -/** - * Node.js built-in modules to externalize - */ -const NODE_BUILTINS = [ - 'assert', - 'buffer', - 'child_process', - 'cluster', - 'crypto', - 'dgram', - 'dns', - 'domain', - 'events', - 'fs', - 'fs/promises', - 'http', - 'http2', - 'https', - 'inspector', - 'module', - 'net', - 'os', - 'path', - 'perf_hooks', - 'process', - 'punycode', - 'querystring', - 'readline', - 'repl', - 'stream', - 'string_decoder', - 'sys', - 'timers', - 'tls', - 'trace_events', - 'tty', - 'url', - 'util', - 'v8', - 'vm', - 'wasi', - 'worker_threads', - 'zlib', -]; - -/** - * Get Vite configuration for browser platform - */ -export function getBrowserConfig(options: NormalizedOptions): UserConfig { - return { - build: { - outDir: `${options.outDir}/browser`, - target: 'esnext', - modulePreload: { polyfill: true }, - rollupOptions: { - input: 'virtual:springboard-entry', - output: { - format: 'es', - entryFileNames: '[name].[hash].js', - chunkFileNames: '[name].[hash].js', - assetFileNames: '[name].[hash][extname]', - }, - }, - }, - resolve: { - conditions: ['browser', 'import', 'module', 'default'], - }, - define: { - __PLATFORM__: JSON.stringify('browser'), - __IS_BROWSER__: 'true', - __IS_NODE__: 'false', - __IS_SERVER__: 'false', - __IS_MOBILE__: 'false', - }, - }; -} - -/** - * Get Vite configuration for Node.js platform - */ -export function getNodeConfig(options: NormalizedOptions): UserConfig { - // List of packages that should be externalized (not bundled) - const externalPackages = [ - ...NODE_BUILTINS, - ...NODE_BUILTINS.map(m => `node:${m}`), - // Externalize React and other peer dependencies - 'react', - 'react-dom', - 'react-dom/server', - 'react/jsx-runtime', - 'react/jsx-dev-runtime', - 'springboard', - 'hono', - '@hono/node-server', - '@hono/node-ws', - 'rxjs', - 'immer', - 'json-rpc-2.0', - /^react\//, // All react imports - /^springboard\//, // All springboard imports - ]; - - return { - build: { - outDir: `${options.outDir}/node`, - target: 'node18', - ssr: true, - rollupOptions: { - input: 'virtual:springboard-entry', - external: externalPackages, - output: { - format: 'es', - entryFileNames: '[name].js', - chunkFileNames: '[name].js', - }, - }, - }, - resolve: { - conditions: ['node', 'import', 'module', 'default'], - }, - ssr: { - target: 'node', - // Note: rollupOptions.external already handles externalization - // ssr.external only accepts string[], not RegExp, so we omit it here - }, - define: { - __PLATFORM__: JSON.stringify('node'), - __IS_BROWSER__: 'false', - __IS_NODE__: 'true', - __IS_SERVER__: 'true', - __IS_MOBILE__: 'false', - }, - }; -} - -/** - * Get Vite configuration for PartyKit platform - */ -export function getPartykitConfig(options: NormalizedOptions): UserConfig { - // List of packages that should be externalized (not bundled) - const externalPackages = [ - /^cloudflare:.*/, - 'partykit', - 'partysocket', - // Externalize React and other peer dependencies - 'react', - 'react-dom', - 'react-dom/server', - 'react/jsx-runtime', - 'react/jsx-dev-runtime', - 'springboard', - /^react\//, - /^springboard\//, - ]; - - return { - build: { - outDir: `${options.outDir}/partykit/server`, - target: 'esnext', - ssr: true, - rollupOptions: { - input: 'virtual:springboard-entry', - external: externalPackages, - output: { - format: 'es', - entryFileNames: 'index.js', - chunkFileNames: '[name].js', - }, - }, - }, - resolve: { - conditions: ['workerd', 'worker', 'browser', 'import', 'module', 'default'], - }, - ssr: { - target: 'webworker', - // Note: rollupOptions.external already handles externalization - // ssr.external only accepts string[], not RegExp, so we omit it here - }, - define: { - __PLATFORM__: JSON.stringify('partykit'), - __IS_BROWSER__: 'false', - __IS_NODE__: 'false', - __IS_SERVER__: 'true', - __IS_MOBILE__: 'false', - __IS_FETCH__: 'true', - }, - }; -} - -/** - * Get Vite configuration for Tauri platform - * Tauri uses browser-like rendering with native APIs - */ -export function getTauriConfig(options: NormalizedOptions): UserConfig { - return { - build: { - outDir: `${options.outDir}/tauri`, - target: 'esnext', - rollupOptions: { - input: 'virtual:springboard-entry', - output: { - format: 'es', - entryFileNames: '[name].[hash].js', - chunkFileNames: '[name].[hash].js', - assetFileNames: '[name].[hash][extname]', - }, - }, - }, - resolve: { - conditions: ['browser', 'import', 'module', 'default'], - }, - define: { - __PLATFORM__: JSON.stringify('tauri'), - __IS_BROWSER__: 'true', - __IS_NODE__: 'false', - __IS_SERVER__: 'false', - __IS_MOBILE__: 'false', - __IS_TAURI__: 'true', - }, - }; -} - -/** - * Get Vite configuration for React Native platform - */ -export function getReactNativeConfig(options: NormalizedOptions): UserConfig { - return { - build: { - outDir: `${options.outDir}/react-native`, - target: 'esnext', - lib: { - entry: 'virtual:springboard-entry', - formats: ['es'], - fileName: 'index', - }, - rollupOptions: { - external: [ - 'react', - 'react-native', - /^@react-native.*/, - /^react-native-.*/, - ], - output: { - format: 'es', - entryFileNames: '[name].js', - }, - }, - }, - resolve: { - conditions: ['react-native', 'import', 'module', 'default'], - }, - define: { - __PLATFORM__: JSON.stringify('react-native'), - __IS_BROWSER__: 'false', - __IS_NODE__: 'false', - __IS_SERVER__: 'false', - __IS_MOBILE__: 'true', - }, - }; -} - -/** - * Get platform-specific Vite configuration - */ -export function getPlatformConfig(options: NormalizedOptions): UserConfig { - switch (options.platform) { - case 'browser': - return getBrowserConfig(options); - case 'node': - return getNodeConfig(options); - case 'partykit': - return getPartykitConfig(options); - case 'tauri': - return getTauriConfig(options); - case 'react-native': - return getReactNativeConfig(options); - default: - return getBrowserConfig(options); - } -} - -/** - * Get resolve conditions for a platform - */ -export function getResolveConditions(platform: Platform): string[] { - switch (platform) { - case 'browser': - case 'tauri': - return ['browser', 'import', 'module', 'default']; - case 'node': - return ['node', 'import', 'module', 'default']; - case 'partykit': - return ['workerd', 'worker', 'browser', 'import', 'module', 'default']; - case 'react-native': - return ['react-native', 'import', 'module', 'default']; - default: - return ['import', 'module', 'default']; - } -} - -/** - * Check if platform is browser-like (renders HTML) - */ -export function isBrowserPlatform(platform: Platform): boolean { - return platform === 'browser' || platform === 'tauri'; -} - -/** - * Check if platform is server-side - */ -export function isServerPlatform(platform: Platform): boolean { - return platform === 'node' || platform === 'partykit'; -} diff --git a/packages/springboard/vite-plugin/src/plugins/build.ts b/packages/springboard/vite-plugin/src/plugins/build.ts deleted file mode 100644 index 1fadd901..00000000 --- a/packages/springboard/vite-plugin/src/plugins/build.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Springboard Build Plugin - * - * This plugin is not used in the simplified monolithic plugin architecture. - * Build configuration is handled directly in the main plugin's config() hook. - */ - -import type { Plugin } from 'vite'; -import type { NormalizedOptions } from '../types.js'; - -export function springboardBuild(options: NormalizedOptions): Plugin | null { - // Return null - build configuration is handled in the main plugin - return null; -} - -export default springboardBuild; diff --git a/packages/springboard/vite-plugin/src/plugins/html.ts b/packages/springboard/vite-plugin/src/plugins/html.ts deleted file mode 100644 index aed20fce..00000000 --- a/packages/springboard/vite-plugin/src/plugins/html.ts +++ /dev/null @@ -1,202 +0,0 @@ -/** - * Springboard HTML Plugin - * - * Generates HTML for browser platforms with proper script/style injection. - * Handles both dev server and production builds. - */ - -import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite'; -import type { NormalizedOptions, DocumentMeta } from '../types.js'; -import { isBrowserPlatform } from '../config/platform-configs.js'; -import { createLogger, escapeHtml } from './shared.js'; - -/** - * Create the springboard HTML plugin. - * - * Responsibilities: - * - Generate HTML template with document metadata - * - Inject scripts and styles in dev mode - * - Generate final HTML with hashed assets in build mode - * - * @param options - Normalized plugin options - * @returns Vite plugin or null if not applicable - */ -export function springboardHtml(options: NormalizedOptions): Plugin | null { - // Only apply for browser-like platforms - if (!isBrowserPlatform(options.platform)) { - return null; - } - - const logger = createLogger('html', options.debug); - let resolvedConfig: ResolvedConfig; - - logger.debug(`HTML plugin initialized for platform: ${options.platform}`); - - return { - name: 'springboard:html', - - /** - * Store resolved config - */ - configResolved(config) { - resolvedConfig = config; - }, - - /** - * Configure dev server to serve HTML - */ - configureServer(server: ViteDevServer) { - return () => { - server.middlewares.use((req, res, next) => { - // Serve HTML for root and index.html requests - if (req.url === '/' || req.url === '/index.html') { - const html = generateHtml(options, true); - - // Let Vite transform the HTML (injects HMR client) - server.transformIndexHtml(req.url, html).then(transformed => { - res.statusCode = 200; - res.setHeader('Content-Type', 'text/html'); - res.end(transformed); - }).catch(next); - return; - } - next(); - }); - }; - }, - - /** - * Transform HTML in build mode - removed to prevent physical index.html creation - * HTML is generated entirely by the generateBundle hook - */ - - /** - * Generate HTML file in build output - */ - async generateBundle(outputOptions, bundle) { - const html = generateHtml(options, false); - - // Collect JS and CSS files from the bundle - const jsFiles: string[] = []; - const cssFiles: string[] = []; - - for (const [fileName] of Object.entries(bundle)) { - if (fileName.endsWith('.map')) continue; - - if (fileName.endsWith('.js')) { - jsFiles.push(fileName); - } else if (fileName.endsWith('.css')) { - cssFiles.push(fileName); - } - } - - // Inject asset references - let finalHtml = html; - - // Add CSS links - const cssLinks = cssFiles - .map(file => ``) - .join('\n '); - finalHtml = finalHtml.replace('', ` ${cssLinks}\n`); - - // Add JS scripts - const jsScripts = jsFiles - .map(file => ``) - .join('\n '); - finalHtml = finalHtml.replace('', ` ${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 ` - - - - - ${escapeHtml(meta.title || 'Springboard App')} - ${metaTags} - - -
- ${scriptTag} - -`; -} - -/** - * Generate meta tags from document metadata - */ -function generateMetaTags(meta: DocumentMeta): string { - const tags: string[] = []; - - // Standard meta tags - if (meta.description) { - tags.push(``); - } - if (meta.keywords) { - tags.push(``); - } - if (meta.author) { - tags.push(``); - } - if (meta.robots) { - tags.push(``); - } - - // Content Security Policy - if (meta['Content-Security-Policy']) { - tags.push(``); - } - - // Open Graph tags - if (meta['og:title']) { - tags.push(``); - } - if (meta['og:description']) { - tags.push(``); - } - if (meta['og:image']) { - tags.push(``); - } - if (meta['og:url']) { - tags.push(``); - } - - // Any other custom meta tags - const handledKeys = [ - 'title', 'description', 'keywords', 'author', 'robots', - 'Content-Security-Policy', - 'og:title', 'og:description', 'og:image', 'og:url' - ]; - - for (const [key, value] of Object.entries(meta)) { - if (!handledKeys.includes(key) && value) { - tags.push(``); - } - } - - return tags.join('\n '); -} - -export default springboardHtml; diff --git a/packages/springboard/vite-plugin/src/plugins/init.ts b/packages/springboard/vite-plugin/src/plugins/init.ts deleted file mode 100644 index 959bf93d..00000000 --- a/packages/springboard/vite-plugin/src/plugins/init.ts +++ /dev/null @@ -1,188 +0,0 @@ -/** - * Springboard Init Plugin - * - * One-time setup, validates config, and provides base configuration. - * This plugin runs first and establishes the foundation for other plugins. - */ - -import type { Plugin, UserConfig } from 'vite'; -import type { NormalizedOptions } from '../types.js'; -import { getPlatformConfig, getResolveConditions } from '../config/platform-configs.js'; -import { createLogger } from './shared.js'; -import { generateBrowserDevEntry, generateBrowserBuildEntry, generateNodeEntry } from '../utils/generate-entry.js'; -import { writeFileSync, mkdirSync, existsSync } from 'fs'; -import * as path from 'path'; - -/** - * Create the springboard init plugin. - * - * Responsibilities: - * - Set appType to 'custom' (we handle HTML ourselves) - * - Configure resolve conditions for platform - * - Set up compile-time defines - * - Apply platform-specific base config - * - * @param options - Normalized plugin options - * @returns Vite plugin - */ -export function springboardInit(options: NormalizedOptions): Plugin { - const logger = createLogger('init', options.debug); - - logger.debug(`Initializing for platform: ${options.platform}`); - - return { - name: 'springboard:init', - enforce: 'pre', - - /** - * Config hook - modify Vite configuration - */ - config(config: UserConfig, env) { - logger.debug(`Config hook called - command: ${env.command}, mode: ${env.mode}`); - - // Get platform-specific base config - const platformConfig = getPlatformConfig(options); - - // Get resolve conditions - const conditions = getResolveConditions(options.platform); - - // Build the enforced configuration - const enforcedConfig: UserConfig = { - // Use custom app type - we handle HTML ourselves - appType: 'custom', - - // Set root directory - root: options.root, - - // Resolve configuration - resolve: { - conditions, - alias: { - // Allow importing platform info - 'virtual:springboard-platform': '\0virtual:springboard-platform', - }, - }, - - // Compile-time defines - define: { - __PLATFORM__: JSON.stringify(options.platform), - __PLATFORM_MACRO__: JSON.stringify(options.platformMacro), - __IS_BROWSER__: String(options.platform === 'browser' || options.platform === 'tauri'), - __IS_NODE__: String(options.platform === 'node'), - __IS_SERVER__: String(options.platform === 'node' || options.platform === 'partykit'), - __IS_MOBILE__: String(options.platform === 'react-native'), - __IS_TAURI__: String(options.platform === 'tauri'), - __IS_PARTYKIT__: String(options.platform === 'partykit'), - __SPRINGBOARD_DEV__: String(env.command === 'serve'), - 'import.meta.env.PLATFORM': JSON.stringify(options.platform), - }, - - // Merge platform-specific build config - build: platformConfig.build, - - // SSR config for server platforms - ...(platformConfig.ssr ? { ssr: platformConfig.ssr } : {}), - }; - - // Apply user's custom viteConfig if provided - if (options.viteConfig) { - const customConfig = options.viteConfig(options.platform, enforcedConfig); - return mergeConfigs(enforcedConfig, customConfig); - } - - return enforcedConfig; - }, - - /** - * Config resolved hook - verify final configuration - */ - configResolved(resolvedConfig) { - logger.debug(`Config resolved - outDir: ${resolvedConfig.build.outDir}`); - - // Warn if user overrode critical settings - if (resolvedConfig.appType !== 'custom') { - logger.warn( - 'appType was changed from "custom". This may cause issues with HTML generation.' - ); - } - }, - - /** - * Build start hook - generate physical entry files - */ - buildStart() { - logger.debug('Generating physical entry files in .springboard/'); - - // Create .springboard directory if it doesn't exist - const springboardDir = path.resolve(options.root, '.springboard'); - if (!existsSync(springboardDir)) { - mkdirSync(springboardDir, { recursive: true }); - } - - // Calculate the relative path from .springboard/ to the user's entry file - const absoluteEntryPath = path.isAbsolute(options.entry) - ? options.entry - : path.resolve(options.root, options.entry); - const relativeEntryPath = path.relative(springboardDir, absoluteEntryPath); - - logger.debug(`Entry path: ${options.entry} -> ${relativeEntryPath}`); - - // Generate entry files based on platform - if (options.platform === 'browser' || options.platform === 'tauri') { - // Generate both dev and build entry files for browser platforms - const devEntryCode = generateBrowserDevEntry(relativeEntryPath); - const buildEntryCode = generateBrowserBuildEntry(relativeEntryPath); - - const devEntryFile = path.join(springboardDir, 'dev-entry.js'); - const buildEntryFile = path.join(springboardDir, 'build-entry.js'); - - writeFileSync(devEntryFile, devEntryCode, 'utf-8'); - writeFileSync(buildEntryFile, buildEntryCode, 'utf-8'); - - logger.debug('Generated browser entry files: dev-entry.js, build-entry.js'); - } else if (options.platform === 'node') { - // Generate node entry file (TypeScript!) - const nodeEntryCode = generateNodeEntry(relativeEntryPath, options.nodeServerPort); - const nodeEntryFile = path.join(springboardDir, 'node-entry.ts'); - - writeFileSync(nodeEntryFile, nodeEntryCode, 'utf-8'); - - logger.debug('Generated node entry file: node-entry.ts'); - } - }, - }; -} - -/** - * Simple config merger - * For deep merging, users should use Vite's mergeConfig - */ -function mergeConfigs(base: UserConfig, override: UserConfig): UserConfig { - return { - ...base, - ...override, - resolve: { - ...base.resolve, - ...override.resolve, - conditions: override.resolve?.conditions ?? base.resolve?.conditions, - alias: { - ...base.resolve?.alias, - ...override.resolve?.alias, - }, - }, - define: { - ...base.define, - ...override.define, - }, - build: { - ...base.build, - ...override.build, - rollupOptions: { - ...base.build?.rollupOptions, - ...override.build?.rollupOptions, - }, - }, - }; -} - -export default springboardInit; diff --git a/packages/springboard/vite-plugin/src/plugins/virtual.ts b/packages/springboard/vite-plugin/src/plugins/virtual.ts deleted file mode 100644 index 678e7567..00000000 --- a/packages/springboard/vite-plugin/src/plugins/virtual.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Springboard Virtual Module Plugin - * - * Creates virtual modules for entry points and platform info. - * These modules are generated dynamically based on configuration. - */ - -import type { Plugin } from 'vite'; -import type { NormalizedOptions } from '../types.js'; -import { VIRTUAL_MODULES, RESOLVED_VIRTUAL_MODULES } from '../types.js'; -import { - generateEntryCode, - generateModulesCode, - generatePlatformCode, -} from '../utils/generate-entry.js'; -import { createLogger } from './shared.js'; - -/** - * Create the springboard virtual module plugin. - * - * Provides virtual modules: - * - virtual:springboard-entry - Auto-generated entry point - * - virtual:springboard-modules - Access to registered modules - * - virtual:springboard-platform - Platform information - * - * @param options - Normalized plugin options - * @returns Vite plugin - */ -export function springboardVirtual(options: NormalizedOptions): Plugin { - const logger = createLogger('virtual', options.debug); - - logger.debug(`Setting up virtual modules for platform: ${options.platform}`); - - return { - name: 'springboard:virtual', - - /** - * Resolve virtual module IDs - */ - resolveId(id: string) { - if (id === VIRTUAL_MODULES.ENTRY) { - logger.debug(`Resolving ${VIRTUAL_MODULES.ENTRY}`); - return RESOLVED_VIRTUAL_MODULES.ENTRY; - } - - if (id === VIRTUAL_MODULES.MODULES) { - logger.debug(`Resolving ${VIRTUAL_MODULES.MODULES}`); - return RESOLVED_VIRTUAL_MODULES.MODULES; - } - - if (id === VIRTUAL_MODULES.PLATFORM) { - logger.debug(`Resolving ${VIRTUAL_MODULES.PLATFORM}`); - return RESOLVED_VIRTUAL_MODULES.PLATFORM; - } - - return null; - }, - - /** - * Load virtual module content - */ - load(id: string) { - if (id === RESOLVED_VIRTUAL_MODULES.ENTRY) { - logger.debug('Loading virtual entry module'); - const code = generateEntryCode(options); - logger.debug(`Generated entry code:\n${code}`); - return code; - } - - if (id === RESOLVED_VIRTUAL_MODULES.MODULES) { - logger.debug('Loading virtual modules module'); - return generateModulesCode(options); - } - - if (id === RESOLVED_VIRTUAL_MODULES.PLATFORM) { - logger.debug('Loading virtual platform module'); - return generatePlatformCode(options); - } - - return null; - }, - }; -} - -export default springboardVirtual; diff --git a/packages/springboard/vite-plugin/src/utils/generate-entry.ts b/packages/springboard/vite-plugin/src/utils/generate-entry.ts deleted file mode 100644 index 4b58ee4d..00000000 --- a/packages/springboard/vite-plugin/src/utils/generate-entry.ts +++ /dev/null @@ -1,320 +0,0 @@ -/** - * Generate Entry Utility - * - * Generates virtual entry point code for different platforms. - * Uses template files for consistent entry generation. - */ - -import type { NormalizedOptions, Platform } from '../types.js'; -import { readFileSync } from 'fs'; -import { fileURLToPath } from 'url'; -import * as path from 'path'; - -/** - * Template loading functions - * - * These functions load template files from src/templates/ directory. - * The path resolution ensures templates are found when running from compiled dist/ directory. - */ - -/** - * Load the browser dev entry template - */ -export function loadBrowserDevTemplate(): string { - const currentDir = path.dirname(fileURLToPath(import.meta.url)); - const templatePath = path.resolve(currentDir, '../../src/templates/browser-dev-entry.template.ts'); - return readFileSync(templatePath, 'utf-8'); -} - -/** - * Load the browser build entry template - */ -export function loadBrowserBuildTemplate(): string { - const currentDir = path.dirname(fileURLToPath(import.meta.url)); - const templatePath = path.resolve(currentDir, '../../src/templates/browser-build-entry.template.ts'); - return readFileSync(templatePath, 'utf-8'); -} - -/** - * Load the node entry template - */ -export function loadNodeTemplate(): string { - const currentDir = path.dirname(fileURLToPath(import.meta.url)); - const templatePath = path.resolve(currentDir, '../../src/templates/node-entry.template.ts'); - return readFileSync(templatePath, 'utf-8'); -} - -/** - * Load the HTML template - */ -export function loadHtmlTemplate(): string { - const currentDir = path.dirname(fileURLToPath(import.meta.url)); - const templatePath = path.resolve(currentDir, '../../src/templates/index.template.html'); - return readFileSync(templatePath, 'utf-8'); -} - -/** - * Template-based entry generation functions - */ - -/** - * Generate browser dev entry code with user entry path injected - * @param userEntryPath - Relative path to user's entry file - * @returns Generated JavaScript code - */ -export function generateBrowserDevEntry(userEntryPath: string): string { - const template = loadBrowserDevTemplate(); - return template.replace('__USER_ENTRY__', userEntryPath); -} - -/** - * Generate browser build entry code with user entry path injected - * @param userEntryPath - Relative path to user's entry file - * @returns Generated JavaScript code - */ -export function generateBrowserBuildEntry(userEntryPath: string): string { - const template = loadBrowserBuildTemplate(); - return template.replace('__USER_ENTRY__', userEntryPath); -} - -/** - * Generate node entry code with user entry path and port injected - * @param userEntryPath - Relative path to user's entry file - * @param port - Port number for the node server (default: 3000) - * @returns Generated TypeScript code - */ -export function generateNodeEntry(userEntryPath: string, port: number = 3000): string { - const template = loadNodeTemplate(); - return template - .replace('__USER_ENTRY__', userEntryPath) - .replace('__PORT__', String(port)); -} - -/** - * Generate HTML with title and description injected - * @param title - Page title (default: 'Springboard App') - * @param description - Page description (optional) - * @returns Generated HTML - */ -export function generateHtml(title?: string, description?: string): string { - const template = loadHtmlTemplate(); - const pageTitle = title || 'Springboard App'; - const descriptionMeta = description - ? `` - : ''; - - return template - .replace('{{TITLE}}', pageTitle) - .replace('{{DESCRIPTION_META}}', descriptionMeta); -} - -/** - * Generate the virtual entry point code for the current platform. - * - * The entry point chains: - * 1. Platform-specific core initialization - * 2. User's application entrypoint - * - * @deprecated Use template-based entry generation functions instead - * @param options - Normalized options - * @returns Generated JavaScript code - */ -export function generateEntryCode(options: NormalizedOptions): string { - const { entry, platform } = options; - - // Resolve entry path (ensure it starts with ./ or /) - const resolvedEntry = entry.startsWith('.') || entry.startsWith('/') - ? entry - : `./${entry}`; - - switch (platform) { - case 'browser': - return generateLegacyBrowserEntry(resolvedEntry); - case 'node': - return generateLegacyNodeEntry(resolvedEntry); - case 'partykit': - return generatePartykitEntry(resolvedEntry); - case 'tauri': - return generateTauriEntry(resolvedEntry); - case 'react-native': - return generateReactNativeEntry(resolvedEntry); - default: - return generateLegacyBrowserEntry(resolvedEntry); - } -} - -/** - * Generate browser entry point (legacy) - * @deprecated Use generateBrowserDevEntry or generateBrowserBuildEntry instead - */ -function generateLegacyBrowserEntry(entry: string): string { - return ` -// Springboard Browser Entry (auto-generated) -import { initBrowser } from 'springboard/platforms/browser'; - -// Import user application -import '${entry}'; - -// Initialize browser platform -const app = initBrowser(); - -export default app; -`.trim(); -} - -/** - * Generate Node.js entry point (legacy) - * @deprecated Use generateNodeEntry instead (loads from template) - */ -function generateLegacyNodeEntry(entry: string): string { - return ` -// Springboard Node Entry (auto-generated) -import { initNode } from 'springboard/platforms/node'; - -// Import user application -import '${entry}'; - -// Initialize Node platform -const app = await initNode(); - -export default app; -`.trim(); -} - -/** - * Generate PartyKit entry point - */ -function generatePartykitEntry(entry: string): string { - return ` -// Springboard PartyKit Entry (auto-generated) -import { initPartykit } from 'springboard/platforms/partykit'; - -// Import user application -import '${entry}'; - -// Initialize PartyKit platform and export server -const server = initPartykit(); - -export default server; -`.trim(); -} - -/** - * Generate Tauri entry point (browser-based with Tauri APIs) - */ -function generateTauriEntry(entry: string): string { - return ` -// Springboard Tauri Entry (auto-generated) -import { initTauri } from 'springboard/platforms/tauri'; - -// Import user application -import '${entry}'; - -// Initialize Tauri platform -const app = initTauri(); - -export default app; -`.trim(); -} - -/** - * Generate React Native entry point - */ -function generateReactNativeEntry(entry: string): string { - return ` -// Springboard React Native Entry (auto-generated) -import { initReactNative } from 'springboard/platforms/react-native'; - -// Import user application -import '${entry}'; - -// Initialize React Native platform -const app = initReactNative(); - -export default app; -`.trim(); -} - -/** - * Generate modules virtual module code. - * This provides access to registered Springboard modules. - * - * @param options - Normalized options - * @returns Generated JavaScript code - */ -export function generateModulesCode(options: NormalizedOptions): string { - return ` -// Springboard Modules (auto-generated) -// This module provides access to registered Springboard modules - -import { getRegisteredModules } from 'springboard/core'; - -export const modules = getRegisteredModules(); -export default modules; -`.trim(); -} - -/** - * Generate platform info virtual module code. - * - * @param options - Normalized options - * @returns Generated JavaScript code - */ -export function generatePlatformCode(options: NormalizedOptions): string { - const { platform, platformMacro, debug } = options; - - return ` -// Springboard Platform Info (auto-generated) - -export const platform = '${platform}'; -export const platformMacro = '${platformMacro}'; -export const isDev = ${process.env.NODE_ENV !== 'production'}; -export const isDebug = ${debug}; - -export const isBrowser = ${platform === 'browser' || platform === 'tauri'}; -export const isNode = ${platform === 'node'}; -export const isPartykit = ${platform === 'partykit'}; -export const isTauri = ${platform === 'tauri'}; -export const isReactNative = ${platform === 'react-native'}; -export const isServer = ${platform === 'node' || platform === 'partykit'}; -export const isClient = ${platform === 'browser' || platform === 'tauri' || platform === 'react-native'}; - -export default { - platform, - platformMacro, - isDev, - isDebug, - isBrowser, - isNode, - isPartykit, - isTauri, - isReactNative, - isServer, - isClient, -}; -`.trim(); -} - -/** - * Get the core files needed for a platform. - * This is used when we need to chain multiple core files. - * - * @param platform - Target platform - * @returns Array of core file paths - */ -export function getCoreFiles(platform: Platform): string[] { - switch (platform) { - case 'browser': - return ['springboard/platforms/browser']; - case 'node': - return ['springboard/platforms/node']; - case 'partykit': - return ['springboard/platforms/partykit']; - case 'tauri': - return ['springboard/platforms/tauri']; - case 'react-native': - return ['springboard/platforms/react-native']; - default: - return ['springboard/platforms/browser']; - } -} diff --git a/packages/springboard/vite-plugin/src/utils/normalize-options.ts b/packages/springboard/vite-plugin/src/utils/normalize-options.ts deleted file mode 100644 index 2e3d5881..00000000 --- a/packages/springboard/vite-plugin/src/utils/normalize-options.ts +++ /dev/null @@ -1,193 +0,0 @@ -/** - * Normalize Options Utility - * - * Transforms user-provided SpringboardOptions into NormalizedOptions - * for internal use by plugins. - */ - -import type { UserConfig } from 'vite'; -import type { - SpringboardOptions, - NormalizedOptions, - Platform, - PlatformMacroTarget, - PlatformConfigFunction, -} from '../types.js'; - -/** - * Maps high-level platforms to platform macro targets - */ -const PLATFORM_TO_MACRO: Record = { - browser: 'browser', - node: 'node', - partykit: 'fetch', - tauri: 'browser', - 'react-native': 'react-native', -}; - -/** - * Default platforms if none specified - */ -const DEFAULT_PLATFORMS: Platform[] = ['browser']; - -/** - * Default output directory - */ -const DEFAULT_OUT_DIR = 'dist'; - -/** - * Default node server port - */ -const DEFAULT_NODE_SERVER_PORT = 1337; - -/** - * Normalize user options into internal options format. - * - * @param options - User-provided options - * @param platform - Current platform being built (defaults to first in platforms array) - * @returns Normalized options for internal use - */ -export function normalizeOptions( - options: SpringboardOptions, - platform?: Platform -): NormalizedOptions { - // Validate entry is provided - if (!options.entry) { - throw new Error('[springboard] Entry point is required'); - } - - // Determine platforms - const platforms = options.platforms && options.platforms.length > 0 - ? options.platforms - : DEFAULT_PLATFORMS; - - // Determine current platform (from env, arg, or default to first) - const currentPlatform = platform - ?? (process.env.SPRINGBOARD_PLATFORM as Platform | undefined) - ?? platforms[0]; - - // Validate current platform is in the list - if (!platforms.includes(currentPlatform)) { - throw new Error( - `[springboard] Platform "${currentPlatform}" is not in the platforms list: ${platforms.join(', ')}` - ); - } - - // Resolve entry for current platform - const entry = resolveEntry(options.entry, currentPlatform); - - // Get platform macro target - const platformMacro = PLATFORM_TO_MACRO[currentPlatform]; - - // Normalize viteConfig to function form - const viteConfig = normalizeViteConfig(options.viteConfig); - - return { - entry, - entryConfig: options.entry, - platforms, - platform: currentPlatform, - platformMacro, - documentMeta: options.documentMeta, - viteConfig, - debug: options.debug ?? false, - partykitName: options.partykitName, - outDir: options.outDir ?? DEFAULT_OUT_DIR, - root: process.cwd(), - nodeServerPort: options.nodeServerPort ?? DEFAULT_NODE_SERVER_PORT, - }; -} - -/** - * Resolve entry point for a specific platform - */ -function resolveEntry(entry: SpringboardOptions['entry'], platform: Platform): string { - if (typeof entry === 'string') { - return entry; - } - - const platformEntry = entry[platform]; - if (!platformEntry) { - // Try to find a fallback - const fallback = entry.browser ?? Object.values(entry)[0]; - if (!fallback) { - throw new Error( - `[springboard] No entry point found for platform "${platform}"` - ); - } - return fallback; - } - - return platformEntry; -} - -/** - * Normalize viteConfig option to function form - */ -function normalizeViteConfig( - viteConfig: SpringboardOptions['viteConfig'] -): PlatformConfigFunction | undefined { - if (!viteConfig) { - return undefined; - } - - if (typeof viteConfig === 'function') { - return viteConfig; - } - - // Convert object form to function form - return (platform: Platform, baseConfig: UserConfig): UserConfig => { - const platformConfig = viteConfig[platform]; - if (!platformConfig) { - return baseConfig; - } - // Simple merge - user can use mergeConfig for deep merge - return { ...baseConfig, ...platformConfig }; - }; -} - -/** - * Create options for a different platform (for multi-platform builds) - */ -export function createOptionsForPlatform( - options: NormalizedOptions, - platform: Platform -): NormalizedOptions { - const entry = resolveEntry(options.entryConfig, platform); - const platformMacro = PLATFORM_TO_MACRO[platform]; - - return { - ...options, - entry, - platform, - platformMacro, - }; -} - -/** - * Validate options and throw helpful errors - */ -export function validateOptions(options: SpringboardOptions): void { - if (!options.entry) { - throw new Error( - '[springboard] Entry point is required. Example: springboard({ entry: "./src/index.tsx" })' - ); - } - - if (options.platforms) { - const validPlatforms: Platform[] = ['browser', 'node', 'partykit', 'tauri', 'react-native']; - for (const platform of options.platforms) { - if (!validPlatforms.includes(platform)) { - throw new Error( - `[springboard] Invalid platform "${platform}". Valid platforms: ${validPlatforms.join(', ')}` - ); - } - } - } - - if (options.platforms?.includes('partykit') && !options.partykitName) { - console.warn( - '[springboard] Warning: PartyKit platform requires "partykitName" option for deployment' - ); - } -} From c44e8becc1c5e3b98f39a57ed2aa98dbef8f394c Mon Sep 17 00:00:00 2001 From: Vibe Kanban Date: Wed, 25 Mar 2026 16:32:39 +0000 Subject: [PATCH 16/22] Committed the deletions as `refactor: remove unused vite plugin modules` in `6a8c1c4`. I left the untracked `.springboard/` directory out of the commit, as requested by your file list. --- .springboard/node-dev-entry.ts | 88 ++++++++++++++++++ .springboard/node-entry.ts | 163 +++++++++++++++++++++++++++++++++ 2 files changed, 251 insertions(+) create mode 100644 .springboard/node-dev-entry.ts create mode 100644 .springboard/node-entry.ts 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); +} From 98cb178a0348f203cd3246715bb7936995fb19ef Mon Sep 17 00:00:00 2001 From: Vibe Kanban Date: Wed, 25 Mar 2026 16:56:17 +0000 Subject: [PATCH 17/22] refactor: store generated vite files in node_modules --- packages/springboard/vite-plugin/src/index.ts | 41 +++++++++++++++---- .../vite-plugin/src/plugins/dev.ts | 2 +- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/packages/springboard/vite-plugin/src/index.ts b/packages/springboard/vite-plugin/src/index.ts index a6770f9b..f857ba9f 100644 --- a/packages/springboard/vite-plugin/src/index.ts +++ b/packages/springboard/vite-plugin/src/index.ts @@ -68,6 +68,8 @@ type RequestInitWithDuplex = RequestInit & { type PlatformKey = 'node' | 'browser' | 'web'; const FALLBACK_HEADER = 'x-springboard-fallback'; +const SPRINGBOARD_GENERATED_DIR = path.join('node_modules', '.springboard'); +const SPRINGBOARD_PUBLIC_PREFIX = '/.springboard'; const createRequestFromNode = (req: IncomingMessage): Request => { const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`); @@ -170,7 +172,7 @@ export function springboard(options: SpringboardOptions): PluginOption { // When running from dist/, we need to go up one level and into src/templates/ const templatesDir = path.resolve(currentDir, '../src/templates'); - // Helper to get project root (where .springboard/ will be created) + // Helper to get project root (where node_modules/.springboard/ will be created) const getProjectRoot = () => { // __dirname would be the test app directory in dev, or node_modules in production // We need to find the actual project root @@ -187,7 +189,7 @@ export function springboard(options: SpringboardOptions): PluginOption { const platformKey = platform === 'browser' ? 'browser' : 'node'; return options.entry[platformKey] ?? options.entry.web ?? options.entry.browser ?? options.entry.node; }; - const SPRINGBOARD_DIR = path.resolve(projectRoot, '.springboard'); + const SPRINGBOARD_DIR = path.resolve(projectRoot, SPRINGBOARD_GENERATED_DIR); const WEB_ENTRY_FILE = path.join(SPRINGBOARD_DIR, 'web-entry.js'); const WEB_HTML_FILE = path.join(projectRoot, 'index.html'); // At project root for Vite const NODE_ENTRY_FILE = path.join(SPRINGBOARD_DIR, 'node-entry.ts'); @@ -236,7 +238,7 @@ export function springboard(options: SpringboardOptions): PluginOption { }, buildStart() { - // Create .springboard directory if it doesn't exist + // Create node_modules/.springboard directory if it doesn't exist if (!existsSync(SPRINGBOARD_DIR)) { mkdirSync(SPRINGBOARD_DIR, { recursive: true }); } @@ -244,13 +246,13 @@ export function springboard(options: SpringboardOptions): PluginOption { // Generate physical entry files based on platform const buildPlatform = hasWeb ? 'browser' : hasNode ? 'node' : null; - // Calculate the correct import path from .springboard/ to the user's entry file + // Calculate the correct import path from node_modules/.springboard/ to the user's entry file const platformEntry = resolveEntry(buildPlatform ?? 'browser'); const absoluteEntryPath = path.isAbsolute(platformEntry) ? platformEntry : path.resolve(projectRoot, platformEntry); - // Then calculate the relative path from .springboard/ to the entry file + // Then calculate the relative path from node_modules/.springboard/ to the entry file const relativeEntryPath = path.relative(SPRINGBOARD_DIR, absoluteEntryPath); if (buildPlatform === 'browser') { @@ -259,10 +261,13 @@ export function springboard(options: SpringboardOptions): PluginOption { writeFileSync(WEB_ENTRY_FILE, webEntryCode, 'utf-8'); // Generate HTML file at project root that references the web entry (relative path for Vite processing) - const buildHtml = generateHtml().replace('/.springboard/dev-entry.js', './.springboard/web-entry.js'); + const buildHtml = generateHtml().replace( + `${SPRINGBOARD_PUBLIC_PREFIX}/web-entry.js`, + `./${SPRINGBOARD_GENERATED_DIR}/web-entry.js` + ); writeFileSync(WEB_HTML_FILE, buildHtml, 'utf-8'); - console.log('[springboard] Generated web entry file in .springboard/'); + console.log('[springboard] Generated web entry file in node_modules/.springboard/'); } else if (buildPlatform === 'node') { // Generate node entry file with user entry injected and port configured const port = options.nodeServerPort ?? 1337; @@ -271,7 +276,7 @@ export function springboard(options: SpringboardOptions): PluginOption { .replace('__PORT__', String(port)); writeFileSync(NODE_ENTRY_FILE, nodeEntryCode, 'utf-8'); - console.log('[springboard] Generated node entry file in .springboard/'); + console.log('[springboard] Generated node entry file in node_modules/.springboard/'); } }, @@ -419,6 +424,24 @@ export function springboard(options: SpringboardOptions): PluginOption { } }); + server.middlewares.use(async (req, res, next) => { + if (!req.url?.startsWith(`${SPRINGBOARD_PUBLIC_PREFIX}/`)) { + next(); + return; + } + + try { + const fileName = req.url.slice(`${SPRINGBOARD_PUBLIC_PREFIX}/`.length); + const filePath = path.join(SPRINGBOARD_DIR, fileName); + const contents = await import('node:fs/promises').then((fs) => fs.readFile(filePath, 'utf-8')); + res.statusCode = 200; + res.setHeader('Content-Type', 'text/javascript'); + res.end(contents); + } catch (err) { + next(err as Error); + } + }); + server.httpServer?.on('upgrade', (req, socket, head) => { if (!currentWs) { return; @@ -461,7 +484,7 @@ export function springboard(options: SpringboardOptions): PluginOption { mkdirSync(SPRINGBOARD_DIR, { recursive: true }); } - // Calculate the correct import path from .springboard/ to the user's entry file + // Calculate the correct import path from node_modules/.springboard/ to the user's entry file const platformEntry = resolveEntry('node'); const absoluteEntryPath = path.isAbsolute(platformEntry) ? platformEntry diff --git a/packages/springboard/vite-plugin/src/plugins/dev.ts b/packages/springboard/vite-plugin/src/plugins/dev.ts index 90bacb51..bebf6c47 100644 --- a/packages/springboard/vite-plugin/src/plugins/dev.ts +++ b/packages/springboard/vite-plugin/src/plugins/dev.ts @@ -185,7 +185,7 @@ export function springboardDev(options: NormalizedOptions): Plugin { return; } - const springboardDir = path.resolve(options.root, '.springboard'); + const springboardDir = path.resolve(options.root, 'node_modules', '.springboard'); const nodeDevEntryFile = path.join(springboardDir, 'node-dev-entry.ts'); if (!existsSync(springboardDir)) { From 8fb45c629747bb0a89e044b7aad22a6881cf3431 Mon Sep 17 00:00:00 2001 From: Vibe Kanban Date: Wed, 25 Mar 2026 21:07:16 +0000 Subject: [PATCH 18/22] edit scripts --- apps/vite-test/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/vite-test/package.json b/apps/vite-test/package.json index a4bf680a..1aedc0d4 100644 --- a/apps/vite-test/package.json +++ b/apps/vite-test/package.json @@ -9,10 +9,10 @@ "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 -- --host 0.0.0.0", + "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", + "dev": "vite --host 0.0.0.0", "check-types": "tsc --noEmit", "start": "node dist/node/node-entry.mjs" }, From 1616cba9fc380bc07de2d60f298fc8869f6a52b9 Mon Sep 17 00:00:00 2001 From: Vibe Kanban Date: Wed, 25 Mar 2026 21:10:47 +0000 Subject: [PATCH 19/22] fix: serve dev web entry through vite transform --- packages/springboard/vite-plugin/src/index.ts | 38 +++---------------- .../src/templates/index.template.html | 2 +- 2 files changed, 7 insertions(+), 33 deletions(-) diff --git a/packages/springboard/vite-plugin/src/index.ts b/packages/springboard/vite-plugin/src/index.ts index f857ba9f..71330487 100644 --- a/packages/springboard/vite-plugin/src/index.ts +++ b/packages/springboard/vite-plugin/src/index.ts @@ -69,7 +69,6 @@ type PlatformKey = 'node' | 'browser' | 'web'; const FALLBACK_HEADER = 'x-springboard-fallback'; const SPRINGBOARD_GENERATED_DIR = path.join('node_modules', '.springboard'); -const SPRINGBOARD_PUBLIC_PREFIX = '/.springboard'; const createRequestFromNode = (req: IncomingMessage): Request => { const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`); @@ -202,14 +201,15 @@ export function springboard(options: SpringboardOptions): PluginOption { ); // Generate HTML for dev and build modes - const generateHtml = (): string => { + const generateHtml = (entryScriptSrc: string): string => { const meta = options.documentMeta || {}; const title = meta.title || 'Springboard App'; const description = meta.description || ''; return htmlTemplate .replace('{{TITLE}}', title) - .replace('{{DESCRIPTION_META}}', description ? `` : ''); + .replace('{{DESCRIPTION_META}}', description ? `` : '') + .replace('{{ENTRY_SCRIPT_SRC}}', entryScriptSrc); }; // Load entry templates @@ -261,10 +261,7 @@ export function springboard(options: SpringboardOptions): PluginOption { writeFileSync(WEB_ENTRY_FILE, webEntryCode, 'utf-8'); // Generate HTML file at project root that references the web entry (relative path for Vite processing) - const buildHtml = generateHtml().replace( - `${SPRINGBOARD_PUBLIC_PREFIX}/web-entry.js`, - `./${SPRINGBOARD_GENERATED_DIR}/web-entry.js` - ); + const buildHtml = generateHtml(`./${SPRINGBOARD_GENERATED_DIR}/web-entry.js`); writeFileSync(WEB_HTML_FILE, buildHtml, 'utf-8'); console.log('[springboard] Generated web entry file in node_modules/.springboard/'); @@ -424,24 +421,6 @@ export function springboard(options: SpringboardOptions): PluginOption { } }); - server.middlewares.use(async (req, res, next) => { - if (!req.url?.startsWith(`${SPRINGBOARD_PUBLIC_PREFIX}/`)) { - next(); - return; - } - - try { - const fileName = req.url.slice(`${SPRINGBOARD_PUBLIC_PREFIX}/`.length); - const filePath = path.join(SPRINGBOARD_DIR, fileName); - const contents = await import('node:fs/promises').then((fs) => fs.readFile(filePath, 'utf-8')); - res.statusCode = 200; - res.setHeader('Content-Type', 'text/javascript'); - res.end(contents); - } catch (err) { - next(err as Error); - } - }); - server.httpServer?.on('upgrade', (req, socket, head) => { if (!currentWs) { return; @@ -464,10 +443,11 @@ export function springboard(options: SpringboardOptions): PluginOption { // Serve HTML for / and /index.html server.middlewares.use((req, res, next) => { if (req.url === '/' || req.url === '/index.html') { + const devEntrySrc = `/@fs/${WEB_ENTRY_FILE.split(path.sep).join('/')}`; res.statusCode = 200; res.setHeader('Content-Type', 'text/html'); // Let Vite transform the HTML (for HMR injection) - server.transformIndexHtml(req.url, generateHtml()).then(transformed => { + server.transformIndexHtml(req.url, generateHtml(devEntrySrc)).then(transformed => { res.end(transformed); }).catch(next); return; @@ -527,12 +507,6 @@ export function springboard(options: SpringboardOptions): PluginOption { }, transformIndexHtml(html, ctx) { - // Only transform HTML in dev mode - in build mode, use the generated file - if (ctx.server) { - // Dev mode: generate HTML dynamically - return generateHtml(); - } - // Build mode: return the HTML as-is (already generated in buildStart) return html; }, }; diff --git a/packages/springboard/vite-plugin/src/templates/index.template.html b/packages/springboard/vite-plugin/src/templates/index.template.html index d9f508e3..585d7d50 100644 --- a/packages/springboard/vite-plugin/src/templates/index.template.html +++ b/packages/springboard/vite-plugin/src/templates/index.template.html @@ -22,6 +22,6 @@ - + From b7b6ea090a3c1ef5d88db63c26310e5685ed49b0 Mon Sep 17 00:00:00 2001 From: Vibe Kanban Date: Tue, 12 May 2026 03:07:03 +0000 Subject: [PATCH 20/22] fix: stop forcing localhost npm registry in ci --- .npmrc | 2 -- verdaccio/run.sh | 18 +++++++++++++++--- 2 files changed, 15 insertions(+), 5 deletions(-) delete mode 100644 .npmrc diff --git a/.npmrc b/.npmrc deleted file mode 100644 index 57fef07c..00000000 --- a/.npmrc +++ /dev/null @@ -1,2 +0,0 @@ -registry=http://localhost:4873/ -//localhost:4873/:_authToken=fake \ No newline at end of file diff --git a/verdaccio/run.sh b/verdaccio/run.sh index 89693e48..066c24ae 100755 --- a/verdaccio/run.sh +++ b/verdaccio/run.sh @@ -4,8 +4,21 @@ # VERDACCIO_PLUGINS_PATH="$BASE_PATH/plugins" \ # docker compose up -echo "registry=http://localhost:4873/" >> ../.npmrc -echo "//localhost:4873/:_authToken=fake" >> ../.npmrc +set -euo pipefail + +TMP_NPMRC="$(mktemp)" +cat > "$TMP_NPMRC" <<'EOF' +registry=http://localhost:4873/ +//localhost:4873/:_authToken=fake +EOF + +export NPM_CONFIG_USERCONFIG="$TMP_NPMRC" + +cleanup() { + rm -f "$TMP_NPMRC" +} + +trap cleanup EXIT npx verdaccio --config ./config/config.yaml @@ -16,4 +29,3 @@ npx verdaccio --config ./config/config.yaml # export npm_config__authToken=fake # export npm_config_registry=http://localhost:4873/ # ./scripts/run-all-folders.sh 0.0.1-dev-jamapp-3 - From 6db3543e92215765c4ecae413b5db778e579d9ab Mon Sep 17 00:00:00 2001 From: Vibe Kanban Date: Sat, 6 Jun 2026 16:11:53 +0000 Subject: [PATCH 21/22] Improve Springboard agent docs --- packages/springboard/cli/src/docs_command.ts | 215 ++++++++++++++---- .../create-springboard-app/src/cli.ts | 173 ++++++++++++-- 2 files changed, 325 insertions(+), 63 deletions(-) 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); From 32c43a1c0cfcd8937f1ea9bb2c2b4a9e7e599188 Mon Sep 17 00:00:00 2001 From: Vibe Kanban Date: Sat, 6 Jun 2026 16:18:17 +0000 Subject: [PATCH 22/22] Fix springboard reset preserving registered modules --- packages/springboard/src/core/engine/register.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/springboard/src/core/engine/register.ts b/packages/springboard/src/core/engine/register.ts index 4abff99d..6a85acae 100644 --- a/packages/springboard/src/core/engine/register.ts +++ b/packages/springboard/src/core/engine/register.ts @@ -109,9 +109,6 @@ export const springboard: SpringboardRegistry = { springboard.registerModule = registerModule; springboard.registerClassModule = registerClassModule; springboard.registerSplashScreen = registerSplashScreen; - clearRegisteredModules(); - clearRegisteredClassModules(); - clearRegisteredSplashScreen(); }, };