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