diff --git a/.claude/agents/fluxstack-core-researcher.md b/.claude/agents/fluxstack-core-researcher.md new file mode 100644 index 00000000..f6f83133 --- /dev/null +++ b/.claude/agents/fluxstack-core-researcher.md @@ -0,0 +1,122 @@ +--- +name: fluxstack-core-researcher +description: Use this agent when you need to deeply understand the FluxStack core framework internals, investigate how core systems work, trace execution flows through the framework, understand plugin hooks, server lifecycle, build system, or any architectural decision within the `core/` directory. This agent is read-only and focuses on analysis and comprehension, never modifying core files.\n\nExamples:\n\n- User: "Como funciona o sistema de plugins do FluxStack?"\n Assistant: "Vou usar o agente fluxstack-core-researcher para investigar o sistema de plugins no core do framework."\n [Uses Task tool to launch fluxstack-core-researcher agent]\n\n- User: "Quero entender o lifecycle do servidor Elysia no FluxStack"\n Assistant: "Deixa eu acionar o fluxstack-core-researcher para analisar o ciclo de vida do servidor."\n [Uses Task tool to launch fluxstack-core-researcher agent]\n\n- User: "Explica como o Live Components funciona internamente no core"\n Assistant: "Vou usar o fluxstack-core-researcher para rastrear a implementação dos Live Components no core."\n [Uses Task tool to launch fluxstack-core-researcher agent]\n\n- User: "Preciso criar um novo plugin, como o sistema de hooks funciona?"\n Assistant: "Primeiro vou usar o fluxstack-core-researcher para entender o sistema de hooks antes de implementar."\n [Uses Task tool to launch fluxstack-core-researcher agent]\n\n- User: "O que o config-schema.ts faz exatamente?"\n Assistant: "Vou acionar o fluxstack-core-researcher para analisar o sistema de configuração no core."\n [Uses Task tool to launch fluxstack-core-researcher agent] +model: sonnet +color: yellow +--- + +You are a senior framework architect and systems analyst specializing in the FluxStack framework. You possess deep expertise in TypeScript, Bun runtime, Elysia.js, WebSocket systems, plugin architectures, and full-stack framework design. Your role is exclusively to **research, analyze, and explain** the FluxStack core system — never to modify it. + +## 🎯 Your Mission + +You are the definitive expert on FluxStack's `core/` directory and its internal workings. You investigate, trace, document, and explain how the framework operates at every level. You help developers understand the system so they can build on top of it correctly. + +## 📁 Your Research Scope + +Your primary focus areas within the FluxStack project: + +1. **`core/server/`** — Elysia server setup, middleware, WebSocket handling, Live Component infrastructure, Room system internals +2. **`core/config/`** — Base configuration system, schema validation, environment loading +3. **`core/utils/`** — Utility functions including `env.ts`, `config-schema.ts`, helper functions +4. **`core/types/`** — Framework type definitions, interfaces, generics +5. **`core/build/`** — Build system, bundling, production optimization +6. **`LLMD/`** — Framework documentation (use as reference but also verify against actual code) +7. **`config/`** — Application configuration files (to understand how they interact with core) +8. **Cross-cutting concerns** — How core connects to `app/`, `plugins/`, and `config/` + +## 🔬 Research Methodology + +When investigating any topic, follow this structured approach: + +### Phase 1: Discovery +- Read the relevant source files thoroughly +- Identify all imports, exports, and dependencies +- Map the file relationships and dependency graph +- Check the LLMD documentation for context + +### Phase 2: Trace Execution +- Follow the execution flow from entry point to completion +- Identify all side effects, state mutations, and I/O operations +- Note any async patterns, event emissions, or lifecycle hooks +- Trace type inference chains through generics and utility types + +### Phase 3: Understand Design Decisions +- Identify the design patterns used (Proxy, Observer, Factory, etc.) +- Understand WHY a particular approach was chosen +- Note trade-offs and limitations +- Compare with alternatives when relevant + +### Phase 4: Synthesize & Explain +- Present findings in clear, structured Portuguese (Brazilian) +- Use code snippets from actual source files to illustrate points +- Create mental models and analogies when helpful +- Highlight connections between subsystems + +## 📋 Output Format + +When presenting your research findings, structure your response as: + +``` +## 🔍 [Topic Being Researched] + +### Resumo +Brief 2-3 sentence summary of findings. + +### Arquivos Analisados +- `path/to/file.ts` — What it does +- `path/to/other.ts` — Its role + +### Como Funciona +Detailed explanation with code references. + +### Fluxo de Execução +Step-by-step execution trace when relevant. + +### Padrões de Design +Design patterns identified and why they're used. + +### Conexões com Outros Sistemas +How this connects to other parts of the framework. + +### ⚠️ Observações Importantes +Gotchas, edge cases, or important notes. +``` + +## 🚨 Critical Rules + +1. **NEVER modify files in `core/`** — You are read-only. The core is framework code and must not be changed. +2. **ALWAYS read actual source code** — Don't rely solely on documentation. Verify claims against the real implementation. +3. **ALWAYS respond in Portuguese (Brazilian)** — The project team works in Portuguese. +4. **ALWAYS cite specific files and line references** when explaining behavior. +5. **NEVER guess or hallucinate** — If you can't find something in the source code, say so explicitly. +6. **ALWAYS trace types** — FluxStack is 100% TypeScript. Understanding type flow is critical. +7. **ALWAYS consider the Bun runtime** — FluxStack runs on Bun, not Node.js. Note Bun-specific APIs and behaviors. +8. **ALWAYS check for Reactive Proxy patterns** — v1.12 introduced Proxy-based state (important for Live Components). + +## 🧠 Domain Knowledge You Must Apply + +- **Elysia.js** patterns: Plugin system, lifecycle hooks, type inference via TypeBox, Eden Treaty integration +- **Bun runtime**: Native APIs, performance characteristics, differences from Node.js +- **WebSocket**: Connection lifecycle, message framing, room-based broadcasting patterns +- **TypeScript advanced**: Conditional types, mapped types, template literal types, type inference chains +- **Reactive patterns**: Proxy-based state management, Observer pattern, event-driven architecture +- **Plugin architecture**: Hook-based extensibility, lifecycle management, security layers (whitelist system) +- **Configuration systems**: Schema-based validation, environment variable loading, type-safe configs + +## 🔄 Self-Verification + +Before presenting any finding: +1. ✅ Did I read the actual source file(s)? +2. ✅ Does my explanation match what the code actually does? +3. ✅ Have I traced the full execution path? +4. ✅ Did I identify all relevant type definitions? +5. ✅ Have I checked for recent changes (v1.12 patterns)? +6. ✅ Is my explanation clear enough for someone unfamiliar with the codebase? + +## 💡 Proactive Behaviors + +- When analyzing a subsystem, proactively identify related subsystems the developer might want to understand next +- Highlight potential pitfalls or common misunderstandings +- Suggest which LLMD documents are most relevant for further reading +- When you discover undocumented behavior, flag it clearly +- If you find discrepancies between documentation and code, report them explicitly diff --git a/.claude/agents/live-components-specialist.md b/.claude/agents/live-components-specialist.md new file mode 100644 index 00000000..89638806 --- /dev/null +++ b/.claude/agents/live-components-specialist.md @@ -0,0 +1,113 @@ +--- +name: live-components-specialist +description: Use this agent when the user needs to create, modify, debug, or understand Live Components in FluxStack. This includes WebSocket-based real-time components, the Room System, reactive state management with Proxy, server-client component architecture, and any questions about the Live Components lifecycle, patterns, or troubleshooting.\n\nExamples:\n\n- user: "Quero criar um componente de chat em tempo real"\n assistant: "Vou usar o agente live-components-specialist para pesquisar os padrões de Live Components e criar o componente de chat."\n \n The user wants to create a real-time chat component. Use the live-components-specialist agent to research Live Component patterns, Room System integration, and build the component following FluxStack conventions.\n \n\n- user: "Meu Live Component não está sincronizando o estado com o frontend"\n assistant: "Vou usar o agente live-components-specialist para diagnosticar o problema de sincronização do seu Live Component."\n \n The user has a state sync issue with a Live Component. Use the live-components-specialist agent to research the reactive state proxy system, check for common anti-patterns, and troubleshoot the issue.\n \n\n- user: "Como funciona o sistema de salas do FluxStack?"\n assistant: "Vou usar o agente live-components-specialist para pesquisar e explicar o Room System do FluxStack."\n \n The user wants to understand the Room System. Use the live-components-specialist agent to read the live-rooms.md documentation and provide a comprehensive explanation.\n \n\n- user: "Preciso adicionar um evento WebSocket customizado no meu componente"\n assistant: "Vou usar o agente live-components-specialist para pesquisar como adicionar eventos WebSocket customizados em Live Components."\n \n The user needs to add custom WebSocket events. Use the live-components-specialist agent to research the event system, $room API, and FluxStackWebSocket interface.\n \n\n- user: "Quero migrar meu componente para usar o novo Reactive State Proxy"\n assistant: "Vou usar o agente live-components-specialist para guiar a migração para o Reactive State Proxy."\n \n The user wants to migrate to the new reactive state pattern. Use the live-components-specialist agent to research the v1.12 changes and guide the migration.\n +model: sonnet +color: green +--- + +You are an expert specialist in FluxStack's Live Components system — the real-time WebSocket-based component architecture that enables server-client state synchronization, multi-room communication, and reactive UI updates. You have deep knowledge of the entire Live Components ecosystem including the Room System, Reactive State Proxy, WebSocket lifecycle, and client-server component linking. + +## Your Identity + +You are a senior real-time systems engineer who has mastered FluxStack's Live Components architecture. You think in terms of state flows, WebSocket connections, room topologies, and reactive synchronization patterns. You combine theoretical understanding with practical implementation expertise. + +## Core Knowledge Areas + +### 1. Live Components Architecture +- Server-side Live Components (`app/server/live/`) extending `LiveComponent` +- Client-side Live Components (`app/client/src/live/`) as React components +- The WebSocket connection lifecycle and re-hydration +- State synchronization between server and client +- The `FluxStackWebSocket` typed interface + +### 2. Reactive State Proxy (v1.12+) +- **New pattern**: `this.state.count++` auto-syncs with frontend via Proxy +- **Legacy pattern**: `this.setState({ count: this.state.count + 1 })` still works for batch updates +- Understanding when to use direct mutation vs `setState()` (batch = one emit) +- Static `defaultState` pattern inside the class + +### 3. Room System ($room API) +- `this.$room(roomId).join()` — joining rooms +- `this.$room(roomId).on(event, callback)` — listening to room events from OTHER users +- `this.$room(roomId).emit(event, data)` — broadcasting to OTHER users in the room +- HTTP API for external integrations (`POST /api/rooms/{roomId}/messages`, `POST /api/rooms/{roomId}/emit`) +- Multi-room patterns and room lifecycle management + +### 4. Component Patterns +- Static `defaultState` (no separate export needed) +- Simplified constructors (only needed for room subscriptions or custom logic) +- Client component links: `import type { Demo as _Client } from '@client/src/live/Demo'` +- Ctrl+Click navigation between server and client components + +## Research Strategy + +When asked about Live Components, you MUST research the codebase thoroughly before answering: + +1. **Always read the documentation first**: + - `LLMD/resources/live-components.md` — Primary Live Components documentation + - `LLMD/resources/live-rooms.md` — Room System documentation + - `LLMD/INDEX.md` — Navigation hub for finding related docs + - `LLMD/patterns/anti-patterns.md` — What NOT to do + - `LLMD/reference/troubleshooting.md` — Common issues and solutions + +2. **Then examine existing implementations**: + - `app/server/live/` — Server-side Live Component implementations + - `app/client/src/live/` — Client-side Live Component implementations + - `core/server/` — Framework internals for WebSocket handling + - `core/types/` — Type definitions for Live Components + +3. **Cross-reference with the framework core** (read-only, for understanding): + - `core/server/` — How WebSocket connections are managed + - `core/types/` — `FluxStackWebSocket` and related interfaces + +## Working Rules + +### ✅ ALWAYS DO: +- Read `LLMD/resources/live-components.md` and `LLMD/resources/live-rooms.md` before answering any Live Component question +- Search for existing Live Component implementations in `app/server/live/` and `app/client/src/live/` to understand current patterns +- Use the Reactive State Proxy pattern (`this.state.prop = value`) for simple state updates +- Use `setState()` for batch updates (multiple properties in one emit) +- Define `static defaultState` inside the class (v1.12+ pattern) +- Use typed `FluxStackWebSocket` instead of `any` for WebSocket parameters +- Include client component link imports for navigation +- Work only in `app/` directory for new components +- Provide both server-side AND client-side code when creating components +- Explain the WebSocket data flow when debugging sync issues +- Use TypeScript with full type safety + +### ❌ NEVER DO: +- Edit files in `core/` (framework is read-only) +- Use `ws: any` instead of `ws: FluxStackWebSocket` +- Export `defaultState` separately (use static class property) +- Forget to handle room cleanup/leave when components disconnect +- Create Live Components without understanding the state sync model +- Skip reading documentation before providing answers +- Use `process.env` directly (use config system) +- Assume patterns without verifying against actual codebase + +## Response Format + +When responding to Live Component questions: + +1. **Research Phase**: Always start by reading relevant documentation files and examining existing implementations +2. **Explanation**: Provide clear explanation of the concept/solution in Portuguese (matching the project's language) +3. **Code Examples**: Show complete, working code for both server and client sides when applicable +4. **Data Flow**: Explain how data flows through WebSocket connections when relevant +5. **Anti-patterns**: Warn about common mistakes related to the specific topic +6. **Testing**: Suggest how to verify the implementation works (curl commands, browser testing, etc.) + +## Language + +Respond in Portuguese (Brazilian) to match the project's documentation language, unless the user explicitly communicates in another language. Code comments can be in English following standard conventions. + +## Quality Checks + +Before providing any Live Component code or guidance: +- ✅ Did I read the relevant LLMD documentation? +- ✅ Did I check existing implementations for current patterns? +- ✅ Is the code using v1.12+ patterns (Reactive State Proxy, static defaultState)? +- ✅ Is `FluxStackWebSocket` used instead of `any`? +- ✅ Are both server and client components addressed? +- ✅ Did I explain the state synchronization flow? +- ✅ Did I warn about relevant anti-patterns? +- ✅ Is all code in `app/` directory (not `core/`)? diff --git a/.gitignore b/.gitignore index 47b5b510..5f6340d6 100644 --- a/.gitignore +++ b/.gitignore @@ -139,5 +139,6 @@ dist chrome_data .chromium core/server/live/auto-generated-components.ts +app/client/.live-stubs/ Fluxstack-Desktop .claude/settings.local.json diff --git a/app/client/src/App.tsx b/app/client/src/App.tsx index c50b3e26..8548bbe0 100644 --- a/app/client/src/App.tsx +++ b/app/client/src/App.tsx @@ -8,6 +8,7 @@ import { UploadDemo } from './live/UploadDemo' import { ChatDemo } from './live/ChatDemo' import { RoomChatDemo } from './live/RoomChatDemo' import { AuthDemo } from './live/AuthDemo' +import { TodoListDemo } from './live/TodoListDemo' import { AppLayout } from './components/AppLayout' import { DemoPage } from './components/DemoPage' import { HomePage } from './pages/HomePage' @@ -127,6 +128,16 @@ function AppContent() { } /> + Lista de tarefas colaborativa usando Live.use() + Room Events!} + > + + + } + /> = { '/chat': '120deg', // verde '/room-chat': '240deg', // azul '/auth': '330deg', // vermelho + '/todo': '45deg', // laranja '/api-test': '90deg', // lima } diff --git a/app/client/src/live/TodoListDemo.tsx b/app/client/src/live/TodoListDemo.tsx new file mode 100644 index 00000000..50b9c601 --- /dev/null +++ b/app/client/src/live/TodoListDemo.tsx @@ -0,0 +1,158 @@ +// TodoListDemo - Lista de tarefas colaborativa em tempo real + +import { useState } from 'react' +import { Live } from '@/core/client' +import { LiveTodoList } from '@server/live/LiveTodoList' + +export function TodoListDemo() { + const [text, setText] = useState('') + + const todoList = Live.use(LiveTodoList, { + room: 'global-todos' + }) + + const handleAdd = async () => { + if (!text.trim()) return + await todoList.addTodo({ text }) + setText('') + } + + const todos = todoList.$state.todos ?? [] + const doneCount = todos.filter((t: any) => t.done).length + const pendingCount = todos.length - doneCount + + return ( +
+

+ Todo List Colaborativo +

+ +

+ Abra em várias abas - todos compartilham a mesma lista! +

+ + {/* Status bar */} +
+
+
+ {todoList.$connected ? 'Conectado' : 'Desconectado'} +
+ +
+ {todoList.$state.connectedUsers} online +
+ +
+ {todoList.$state.totalCreated} criadas +
+
+ + {/* Input */} +
+ setText(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleAdd()} + placeholder="Nova tarefa..." + className="flex-1 bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder-gray-500 focus:outline-none focus:border-purple-500/50 transition-colors" + /> + +
+ + {/* Stats */} + {todos.length > 0 && ( +
+ {pendingCount} pendente(s) / {doneCount} feita(s) + {doneCount > 0 && ( + + )} +
+ )} + + {/* Todo list */} +
+ {todos.length === 0 ? ( +

+ Nenhuma tarefa ainda. Adicione uma acima! +

+ ) : ( + todos.map((todo: any) => ( +
+ + + + {todo.text} + + + + {todo.createdBy} + + + +
+ )) + )} +
+ + {/* Loading indicator */} + {todoList.$loading && ( +
+
+
+ )} + +
+

+ Usando Live.use() + Room Events +

+
+
+ ) +} diff --git a/app/server/live/LiveTodoList.ts b/app/server/live/LiveTodoList.ts new file mode 100644 index 00000000..8f7d8eb1 --- /dev/null +++ b/app/server/live/LiveTodoList.ts @@ -0,0 +1,110 @@ +// LiveTodoList - Lista de tarefas colaborativa em tempo real +// Testa: state mutations, room events, multiple actions, arrays no state + +import { LiveComponent, type FluxStackWebSocket } from '@core/types/types' + +// Componente Cliente (Ctrl+Click para navegar) +import type { TodoListDemo as _Client } from '@client/src/live/TodoListDemo' + +interface TodoItem { + id: string + text: string + done: boolean + createdBy: string + createdAt: number +} + +export class LiveTodoList extends LiveComponent { + static componentName = 'LiveTodoList' + static publicActions = ['addTodo', 'toggleTodo', 'removeTodo', 'clearCompleted'] as const + static defaultState = { + todos: [] as TodoItem[], + totalCreated: 0, + connectedUsers: 0 + } + protected roomType = 'todo' + + constructor(initialState: Partial = {}, ws: FluxStackWebSocket, options?: { room?: string; userId?: string }) { + super(initialState, ws, options) + + this.onRoomEvent<{ todos: TodoItem[]; totalCreated: number }>('TODOS_CHANGED', (data) => { + this.setState({ todos: data.todos, totalCreated: data.totalCreated }) + }) + + this.onRoomEvent<{ connectedUsers: number }>('USER_COUNT_CHANGED', (data) => { + this.setState({ connectedUsers: data.connectedUsers }) + }) + + const newCount = this.state.connectedUsers + 1 + this.emitRoomEventWithState('USER_COUNT_CHANGED', { connectedUsers: newCount }, { connectedUsers: newCount }) + } + + async addTodo(payload: { text: string }) { + if (!payload.text?.trim()) { + return { success: false, error: 'Text is required' } + } + + const todo: TodoItem = { + id: `todo-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + text: payload.text.trim(), + done: false, + createdBy: this.userId || 'anonymous', + createdAt: Date.now() + } + + const nextTodos = [...this.state.todos, todo] + const nextTotal = this.state.totalCreated + 1 + + this.emitRoomEventWithState( + 'TODOS_CHANGED', + { todos: nextTodos, totalCreated: nextTotal }, + { todos: nextTodos, totalCreated: nextTotal } + ) + + return { success: true, todo } + } + + async toggleTodo(payload: { id: string }) { + const nextTodos = this.state.todos.map(t => + t.id === payload.id ? { ...t, done: !t.done } : t + ) + + this.emitRoomEventWithState( + 'TODOS_CHANGED', + { todos: nextTodos, totalCreated: this.state.totalCreated }, + { todos: nextTodos } + ) + + return { success: true } + } + + async removeTodo(payload: { id: string }) { + const nextTodos = this.state.todos.filter(t => t.id !== payload.id) + + this.emitRoomEventWithState( + 'TODOS_CHANGED', + { todos: nextTodos, totalCreated: this.state.totalCreated }, + { todos: nextTodos } + ) + + return { success: true } + } + + async clearCompleted() { + const nextTodos = this.state.todos.filter(t => !t.done) + + this.emitRoomEventWithState( + 'TODOS_CHANGED', + { todos: nextTodos, totalCreated: this.state.totalCreated }, + { todos: nextTodos } + ) + + return { success: true, removed: this.state.todos.length - nextTodos.length } + } + + destroy() { + const newCount = Math.max(0, this.state.connectedUsers - 1) + this.emitRoomEvent('USER_COUNT_CHANGED', { connectedUsers: newCount }) + super.destroy() + } +} diff --git a/core/build/vite-plugin-live-strip.ts b/core/build/vite-plugin-live-strip.ts new file mode 100644 index 00000000..ae2c4646 --- /dev/null +++ b/core/build/vite-plugin-live-strip.ts @@ -0,0 +1,188 @@ +/** + * FluxStack Vite Plugin — strips server code from @server/live/* imports. + * + * Client components import server LiveComponent classes for type inference, + * but only need 3 static properties: componentName, defaultState, publicActions. + * + * This plugin intercepts those imports and redirects them to tiny .js stubs + * inside app/client/.live-stubs/ that export only those 3 properties. + */ + +import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync } from 'fs' +import { resolve, dirname, join } from 'path' +import type { Plugin, ModuleNode } from 'vite' + +// Stubs are generated inside the Vite root (app/client/) so they're served normally +const STUB_DIR_NAME = '.live-stubs' + +// ── Metadata extraction ────────────────────────────────────────────── + +interface ComponentMeta { + className: string + componentName: string + defaultState: string // raw JS object literal (type casts stripped) + publicActions: string // raw JS array literal +} + +/** Read a server .ts file and pull out the 3 static fields we need. */ +function extractMeta(filePath: string): ComponentMeta[] { + const src = readFileSync(filePath, 'utf-8') + const results: ComponentMeta[] = [] + + // Find each `export class Foo extends LiveComponent` + const re = /export\s+class\s+(\w+)\s+extends\s+LiveComponent/g + let m: RegExpExecArray | null + + while ((m = re.exec(src)) !== null) { + const className = m[1] + const body = extractBlock(src, src.indexOf('{', m.index)) + + const name = body.match(/static\s+componentName\s*=\s*['"]([^'"]+)['"]/)?.[1] ?? className + const actions = body.match(/static\s+publicActions\s*=\s*(\[[^\]]*\])/)?.[1] ?? '[]' + const state = extractDefaultState(body) + + results.push({ className, componentName: name, defaultState: state, publicActions: actions }) + } + + return results +} + +/** Extract a brace-balanced block starting at position `start`. */ +function extractBlock(src: string, start: number): string { + let depth = 1, i = start + 1 + while (i < src.length && depth > 0) { + if (src[i] === '{') depth++ + else if (src[i] === '}') depth-- + i++ + } + return src.substring(start, i) +} + +/** Pull out `static defaultState = { ... }` and strip TS type casts. */ +function extractDefaultState(classBody: string): string { + const m = classBody.match(/static\s+defaultState\s*=\s*/) + if (!m) return '{}' + + const objStart = classBody.indexOf('{', m.index! + m[0].length) + if (objStart === -1) return '{}' + + const raw = extractBlock(classBody, objStart) + return stripAsCasts(raw) +} + +/** + * Remove `as ` casts, handling nested generics/brackets. + * e.g. `null as string | null` → `null` + * `[] as { id: string }[]` → `[]` + * `{} as Record` → `{}` + */ +function stripAsCasts(s: string): string { + const RE = /\s+as\s+/g + let out = '', last = 0, m: RegExpExecArray | null + + while ((m = RE.exec(s)) !== null) { + out += s.slice(last, m.index) + let i = m.index + m[0].length + const stack: string[] = [] + + while (i < s.length) { + const c = s[i] + if (c === '{' || c === '<' || c === '(') { stack.push(c === '{' ? '}' : c === '<' ? '>' : ')'); i++ } + else if (c === '[' && s[i + 1] === ']') { i += 2 } + else if (c === '[') { stack.push(']'); i++ } + else if (stack.length && c === stack[stack.length - 1]) { stack.pop(); i++; while (s[i] === '[' && s[i + 1] === ']') i += 2 } + else if (!stack.length && (c === ',' || c === '\n' || c === '}')) break + else i++ + } + last = i + } + + return out + s.slice(last) +} + +// ── Stub generation ────────────────────────────────────────────────── + +function buildStub(metas: ComponentMeta[]): string { + if (!metas.length) return 'export {}' + return metas.map(m => + `export class ${m.className} {\n` + + ` static componentName = '${m.componentName}'\n` + + ` static defaultState = ${m.defaultState}\n` + + ` static publicActions = ${m.publicActions}\n` + + `}` + ).join('\n\n') +} + +// ── Plugin ─────────────────────────────────────────────────────────── + +function norm(p: string) { return p.replace(/\\/g, '/') } + +export function fluxstackLiveStripPlugin(): Plugin { + let projectRoot: string + let stubDir: string + const nameToFile = new Map() + const fileToName = new Map() + const cache = new Map() + + function writeStub(name: string, serverPath: string): string { + const stubPath = join(stubDir, `${name}.js`) + const content = buildStub(extractMeta(serverPath)) + if (cache.get(name) !== content) { + writeFileSync(stubPath, content, 'utf-8') + cache.set(name, content) + } + return stubPath + } + + return { + name: 'fluxstack-live-strip', + enforce: 'pre', + + configResolved(config) { + projectRoot = config.configFile ? dirname(config.configFile) : resolve(config.root, '../..') + stubDir = join(config.root, STUB_DIR_NAME) + if (!existsSync(stubDir)) mkdirSync(stubDir, { recursive: true }) + }, + + resolveId(source, importer) { + if (!source.startsWith('@server/live/') || !importer) return null + const imp = norm(importer) + if (!imp.includes('/app/client/') && !imp.includes('/core/client/')) return null + + const name = source.replace('@server/live/', '') + const abs = resolve(projectRoot, source.replace('@server/', 'app/server/')) + const ts = abs.endsWith('.ts') ? abs : abs + '.ts' + + nameToFile.set(name, ts) + fileToName.set(norm(ts), name) + + return writeStub(name, ts) + }, + + handleHotUpdate({ file, server }): ModuleNode[] | void { + const name = fileToName.get(norm(file)) + if (!name) return + + const serverPath = nameToFile.get(name)! + const oldContent = cache.get(name) + const newContent = buildStub(extractMeta(serverPath)) + + if (newContent === oldContent) return [] + + writeStub(name, serverPath) + + const stubPath = norm(join(stubDir, `${name}.js`)) + const mods = server.moduleGraph.getModulesByFile(stubPath) + if (mods?.size) { + const arr = [...mods] + arr.forEach(m => server.moduleGraph.invalidateModule(m)) + server.config.logger.info(`[live-strip] HMR: ${name} metadata changed`, { timestamp: true }) + return arr + } + }, + + buildEnd() { + if (existsSync(stubDir)) rmSync(stubDir, { recursive: true, force: true }) + }, + } +} diff --git a/core/build/vite-plugins.ts b/core/build/vite-plugins.ts new file mode 100644 index 00000000..06fdc043 --- /dev/null +++ b/core/build/vite-plugins.ts @@ -0,0 +1,28 @@ +/** + * FluxStack internal Vite plugins. + * + * Returns all framework-level Vite plugins that should be registered + * automatically. Consumers just call `fluxstackVitePlugins()` in their + * vite.config.ts — no need to know about individual internal plugins. + */ + +import type { Plugin } from 'vite' +import { resolve } from 'path' +import tsconfigPaths from 'vite-tsconfig-paths' +import checker from 'vite-plugin-checker' +import { fluxstackLiveStripPlugin } from './vite-plugin-live-strip' +import { helpers } from '../utils/env' + +export function fluxstackVitePlugins(): Plugin[] { + return [ + fluxstackLiveStripPlugin(), + tsconfigPaths({ + projects: [resolve(import.meta.dirname, '..', '..', 'tsconfig.json')] + }), + // Only run type checker in development (saves ~5+ minutes in Docker builds) + helpers.isDevelopment() && checker({ + typescript: true, + overlay: true + }), + ].filter(Boolean) as Plugin[] +} diff --git a/tests/unit/core/server-client-leak.test.ts b/tests/unit/core/server-client-leak.test.ts new file mode 100644 index 00000000..d2686d65 --- /dev/null +++ b/tests/unit/core/server-client-leak.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest' +import { readFileSync, readdirSync } from 'fs' +import { resolve } from 'path' + +/** + * Server-Client Code Leak Detection Tests + * + * Documents and verifies that the server→client leak problem exists + * (LiveComponent base class has runtime server imports) and that + * the fix (fluxstack-live-strip plugin) is configured. + */ + +const ROOT = resolve(__dirname, '../../..') + +describe('Server-Client Code Leak Detection', () => { + describe('LiveComponent base class has runtime server imports (the problem)', () => { + it('types.ts imports server-only modules at runtime (not type-only)', () => { + const typesContent = readFileSync( + resolve(ROOT, 'core/types/types.ts'), + 'utf-8' + ) + + // These runtime imports would leak into the client bundle without the plugin + const serverImports = [ + "import { roomEvents } from '@core/server/live/RoomEventBus'", + "import { liveRoomManager } from '@core/server/live/LiveRoomManager'", + "import { ANONYMOUS_CONTEXT } from '@core/server/live/auth/LiveAuthContext'", + "import { liveLog, liveWarn } from '@core/server/live/LiveLogger'", + ] + + const found = serverImports.filter(imp => typesContent.includes(imp)) + expect(found.length).toBeGreaterThan(0) + }) + }) + + describe('Client components use runtime imports from @server/live/', () => { + it('client live components import server classes (not type-only)', () => { + const clientLiveDir = resolve(ROOT, 'app/client/src/live') + const clientFiles = readdirSync(clientLiveDir).filter((f: string) => + f.endsWith('.tsx') || f.endsWith('.ts') + ) + + const withServerImport: string[] = [] + + for (const file of clientFiles) { + const content = readFileSync(resolve(clientLiveDir, file), 'utf-8') + if (/import\s+\{[^}]+\}\s+from\s+['"]@server\/live\//.test(content)) { + withServerImport.push(file) + } + } + + // At least some client components import from @server/live/ + expect(withServerImport.length).toBeGreaterThan(0) + }) + }) + + describe('Fix: live-strip plugin is configured', () => { + it('vite config uses fluxstackVitePlugins which includes the strip plugin', () => { + const viteConfig = readFileSync(resolve(ROOT, 'vite.config.ts'), 'utf-8') + expect(viteConfig).toContain('fluxstackVitePlugins') + }) + + it('vite-plugin-live-strip.ts exports the plugin', () => { + const pluginSource = readFileSync( + resolve(ROOT, 'core/build/vite-plugin-live-strip.ts'), + 'utf-8' + ) + + expect(pluginSource).toContain('export function fluxstackLiveStripPlugin') + expect(pluginSource).toContain("name: 'fluxstack-live-strip'") + expect(pluginSource).toContain('@server/live/') + }) + }) +}) diff --git a/tests/unit/core/vite-plugin-live-strip.test.ts b/tests/unit/core/vite-plugin-live-strip.test.ts new file mode 100644 index 00000000..e328b0e7 --- /dev/null +++ b/tests/unit/core/vite-plugin-live-strip.test.ts @@ -0,0 +1,414 @@ +import { describe, it, expect } from 'vitest' +import { readFileSync } from 'fs' +import { resolve } from 'path' + +/** + * Tests for the FluxStack Vite Plugin - Live Component Server Code Stripping + * + * Verifies that the plugin correctly: + * 1. Extracts static metadata from server live components + * 2. Generates client-safe stubs without server dependencies + * 3. Strips TypeScript type casts from defaultState + * 4. Preserves the class structure needed by Live.use() + * 5. Detects metadata changes for HMR (ignores server-only changes) + */ + +const ROOT = resolve(__dirname, '../../..') + +// ── Replicate the plugin's internal logic for testing ──────────────── + +function extractBlock(src: string, start: number): string { + let depth = 1, i = start + 1 + while (i < src.length && depth > 0) { + if (src[i] === '{') depth++ + else if (src[i] === '}') depth-- + i++ + } + return src.substring(start, i) +} + +function stripAsCasts(s: string): string { + const RE = /\s+as\s+/g + let out = '', last = 0, m: RegExpExecArray | null + + while ((m = RE.exec(s)) !== null) { + out += s.slice(last, m.index) + let i = m.index + m[0].length + const stack: string[] = [] + + while (i < s.length) { + const c = s[i] + if (c === '{' || c === '<' || c === '(') { stack.push(c === '{' ? '}' : c === '<' ? '>' : ')'); i++ } + else if (c === '[' && s[i + 1] === ']') { i += 2 } + else if (c === '[') { stack.push(']'); i++ } + else if (stack.length && c === stack[stack.length - 1]) { stack.pop(); i++; while (s[i] === '[' && s[i + 1] === ']') i += 2 } + else if (!stack.length && (c === ',' || c === '\n' || c === '}')) break + else i++ + } + last = i + } + + return out + s.slice(last) +} + +function extractDefaultState(classBody: string): string { + const m = classBody.match(/static\s+defaultState\s*=\s*/) + if (!m) return '{}' + const objStart = classBody.indexOf('{', m.index! + m[0].length) + if (objStart === -1) return '{}' + const raw = extractBlock(classBody, objStart) + return stripAsCasts(raw) +} + +function extractComponentMetadata(source: string) { + const components: { + className: string + componentName: string | null + defaultState: string | null + publicActions: string | null + }[] = [] + + const classRegex = /export\s+class\s+(\w+)\s+extends\s+LiveComponent/g + let classMatch + + while ((classMatch = classRegex.exec(source)) !== null) { + const className = classMatch[1] + const classStartIndex = source.indexOf('{', classMatch.index) + if (classStartIndex === -1) continue + + const classBody = extractBlock(source, classStartIndex) + + const componentNameMatch = classBody.match( + /static\s+componentName\s*=\s*['"]([^'"]+)['"]/ + ) + const componentName = componentNameMatch ? componentNameMatch[1] : null + + const defaultState = extractDefaultState(classBody) + + const publicActionsMatch = classBody.match( + /static\s+publicActions\s*=\s*(\[[^\]]*\])/ + ) + const publicActions = publicActionsMatch ? publicActionsMatch[1] : null + + components.push({ className, componentName, defaultState, publicActions }) + } + + return components +} + +function generateClientStub(source: string): string { + const components = extractComponentMetadata(source) + if (components.length === 0) return 'export {}' + + return components.map(comp => { + const componentName = comp.componentName || comp.className + const defaultState = comp.defaultState || '{}' + const publicActions = comp.publicActions || '[]' + + return `export class ${comp.className} {\n` + + ` static componentName = '${componentName}'\n` + + ` static defaultState = ${defaultState}\n` + + ` static publicActions = ${publicActions}\n` + + `}` + }).join('\n\n') +} + +// ── Tests ──────────────────────────────────────────────────────────── + +describe('Vite Plugin - Live Component Server Code Stripping', () => { + describe('extractComponentMetadata — real components', () => { + it('should extract metadata from LiveCounter', () => { + const source = readFileSync( + resolve(ROOT, 'app/server/live/LiveCounter.ts'), + 'utf-8' + ) + + const metadata = extractComponentMetadata(source) + expect(metadata).toHaveLength(1) + expect(metadata[0].className).toBe('LiveCounter') + expect(metadata[0].componentName).toBe('LiveCounter') + expect(metadata[0].publicActions).toContain('increment') + expect(metadata[0].publicActions).toContain('decrement') + expect(metadata[0].publicActions).toContain('reset') + expect(metadata[0].defaultState).toBeTruthy() + expect(metadata[0].defaultState).toContain('count') + }) + + it('should extract metadata from LiveChat', () => { + const source = readFileSync( + resolve(ROOT, 'app/server/live/LiveChat.ts'), + 'utf-8' + ) + + const metadata = extractComponentMetadata(source) + expect(metadata).toHaveLength(1) + expect(metadata[0].className).toBe('LiveChat') + expect(metadata[0].componentName).toBe('LiveChat') + }) + + it('should extract metadata from LiveTodoList', () => { + const source = readFileSync( + resolve(ROOT, 'app/server/live/LiveTodoList.ts'), + 'utf-8' + ) + + const metadata = extractComponentMetadata(source) + expect(metadata).toHaveLength(1) + expect(metadata[0].className).toBe('LiveTodoList') + expect(metadata[0].componentName).toBe('LiveTodoList') + expect(metadata[0].publicActions).toContain('addTodo') + expect(metadata[0].publicActions).toContain('toggleTodo') + expect(metadata[0].publicActions).toContain('removeTodo') + expect(metadata[0].publicActions).toContain('clearCompleted') + expect(metadata[0].defaultState).toContain('todos') + expect(metadata[0].defaultState).toContain('totalCreated') + }) + }) + + describe('generateClientStub — stripping', () => { + it('should strip server imports and produce clean stub for LiveCounter', () => { + const source = readFileSync( + resolve(ROOT, 'app/server/live/LiveCounter.ts'), + 'utf-8' + ) + + const stub = generateClientStub(source) + + // No server imports + expect(stub).not.toContain("from '@core/types/types'") + expect(stub).not.toContain("from '@core/server/") + expect(stub).not.toContain('RoomEventBus') + expect(stub).not.toContain('LiveRoomManager') + expect(stub).not.toContain('FluxStackWebSocket') + + // Has metadata + expect(stub).toContain('export class LiveCounter') + expect(stub).toContain("static componentName = 'LiveCounter'") + expect(stub).toContain('static defaultState =') + expect(stub).toContain('count') + }) + + it('should strip TypeScript type casts from defaultState', () => { + const source = ` +export class TestComponent extends LiveComponent { + static componentName = 'TestComponent' + static publicActions = ['doSomething'] as const + static defaultState = { + name: null as string | null, + items: [] as string[], + count: 0 + } + + async doSomething() {} +} +` + const stub = generateClientStub(source) + + expect(stub).not.toContain('as string | null') + expect(stub).not.toContain('as string[]') + expect(stub).toContain('null') + expect(stub).toContain('[]') + expect(stub).toContain('0') + }) + + it('should strip complex type casts with generics', () => { + const source = ` +export class Complex extends LiveComponent { + static componentName = 'Complex' + static publicActions = [] as const + static defaultState = { + data: {} as Record, + list: [] as { id: string }[] + } +} +` + const stub = generateClientStub(source) + + expect(stub).not.toContain('Record') + expect(stub).not.toContain('{ id: string }[]') + expect(stub).toContain('{}') + expect(stub).toContain('[]') + }) + + it('should handle components without publicActions', () => { + const source = ` +export class NoActionsComponent extends LiveComponent { + static componentName = 'NoActionsComponent' + static defaultState = { value: 0 } +} +` + const stub = generateClientStub(source) + + expect(stub).toContain('export class NoActionsComponent') + expect(stub).toContain("static componentName = 'NoActionsComponent'") + expect(stub).toContain('static publicActions = []') + }) + + it('should return empty export for non-LiveComponent files', () => { + const source = ` +export function helperFunction() { + return 42 +} + +export const CONSTANT = 'hello' +` + const stub = generateClientStub(source) + expect(stub).toBe('export {}') + }) + + it('should not contain method implementations in stubs', () => { + const source = readFileSync( + resolve(ROOT, 'app/server/live/LiveTodoList.ts'), + 'utf-8' + ) + + const stub = generateClientStub(source) + + expect(stub).not.toContain('async addTodo') + expect(stub).not.toContain('async toggleTodo') + expect(stub).not.toContain('async removeTodo') + expect(stub).not.toContain('this.setState') + expect(stub).not.toContain('this.emitRoomEvent') + }) + }) + + describe('Live.use() compatibility', () => { + it('stub should provide all properties that Live.use() accesses', () => { + const source = readFileSync( + resolve(ROOT, 'app/server/live/LiveCounter.ts'), + 'utf-8' + ) + + const stub = generateClientStub(source) + + expect(stub).toContain('static componentName') + expect(stub).toContain('static defaultState') + expect(stub).toContain('static publicActions') + }) + + it('stub class should be evaluable as valid JavaScript', () => { + const source = ` +export class LiveTest extends LiveComponent { + static componentName = 'LiveTest' + static publicActions = ['greet'] as const + static defaultState = { + message: 'hello', + count: 0 + } + + async greet(payload: { name: string }) { + return { greeting: 'Hello ' + payload.name } + } +} +` + const stub = generateClientStub(source) + + const evalFn = new Function(` + ${stub.replace(/export /g, '')} + return LiveTest + `) + + const LiveTest = evalFn() + expect(LiveTest.componentName).toBe('LiveTest') + expect(LiveTest.defaultState).toEqual({ message: 'hello', count: 0 }) + }) + }) + + describe('HMR: metadata change detection', () => { + it('same metadata should produce identical stubs (no unnecessary HMR)', () => { + const source = ` +export class LiveWidget extends LiveComponent { + static componentName = 'LiveWidget' + static publicActions = ['doStuff'] as const + static defaultState = { value: 0 } + + async doStuff() { + console.log('v1') + return { ok: true } + } +} +` + const stub1 = generateClientStub(source) + + // Change only the method body (server-side only) + const sourceV2 = source.replace("console.log('v1')", "console.log('v2 - refactored')") + const stub2 = generateClientStub(sourceV2) + + expect(stub1).toBe(stub2) + }) + + it('changed defaultState should produce different stubs (triggers HMR)', () => { + const sourceV1 = ` +export class LiveWidget extends LiveComponent { + static componentName = 'LiveWidget' + static publicActions = ['doStuff'] as const + static defaultState = { value: 0 } + async doStuff() { return { ok: true } } +} +` + const sourceV2 = sourceV1.replace( + 'static defaultState = { value: 0 }', + "static defaultState = { value: 0, label: 'new field' }" + ) + + const stub1 = generateClientStub(sourceV1) + const stub2 = generateClientStub(sourceV2) + + expect(stub1).not.toBe(stub2) + expect(stub2).toContain('label') + }) + + it('changed publicActions should produce different stubs (triggers HMR)', () => { + const sourceV1 = ` +export class LiveWidget extends LiveComponent { + static componentName = 'LiveWidget' + static publicActions = ['doStuff'] as const + static defaultState = { value: 0 } + async doStuff() { return { ok: true } } +} +` + const sourceV2 = sourceV1.replace( + "static publicActions = ['doStuff'] as const", + "static publicActions = ['doStuff', 'doMore'] as const" + ) + + const stub1 = generateClientStub(sourceV1) + const stub2 = generateClientStub(sourceV2) + + expect(stub1).not.toBe(stub2) + expect(stub2).toContain('doMore') + }) + }) + + describe('All server live components should produce valid stubs', () => { + const { readdirSync } = require('fs') + const liveDir = resolve(ROOT, 'app/server/live') + const liveFiles = readdirSync(liveDir) + .filter((f: string) => f.endsWith('.ts') && f !== 'register-components.ts') + + for (const file of liveFiles) { + it(`should generate a valid stub for ${file}`, () => { + const source = readFileSync(resolve(liveDir, file), 'utf-8') + const stub = generateClientStub(source) + + // Stub should not contain server-side imports + expect(stub).not.toContain("from 'fs'") + expect(stub).not.toContain("from 'path'") + expect(stub).not.toContain("from 'os'") + expect(stub).not.toContain("from '@core/types/types'") + expect(stub).not.toContain("from '@core/server/") + expect(stub).not.toContain("from 'bun'") + + // If it has a LiveComponent class, stub should have the class + const metadata = extractComponentMetadata(source) + if (metadata.length > 0) { + for (const comp of metadata) { + expect(stub).toContain(`export class ${comp.className}`) + expect(stub).toContain('static componentName') + expect(stub).toContain('static defaultState') + } + } + }) + } + }) +}) diff --git a/vite.config.ts b/vite.config.ts index adf2c05c..d96bef56 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,11 +1,9 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' -import tsconfigPaths from 'vite-tsconfig-paths' -import checker from 'vite-plugin-checker' import { resolve } from 'path' import { clientConfig } from './config/system/client.config' -import { helpers } from './core/utils/env' +import { fluxstackVitePlugins } from './core/build/vite-plugins' // Root directory (vite.config.ts is in project root) const rootDir = import.meta.dirname @@ -13,17 +11,11 @@ const rootDir = import.meta.dirname // https://vite.dev/config/ export default defineConfig({ plugins: [ + // FluxStack internal plugins (live-strip, tsconfig-paths, type-checker) + ...fluxstackVitePlugins(), react(), tailwindcss(), - tsconfigPaths({ - projects: [resolve(rootDir, 'tsconfig.json')] - }), - // Only run type checker in development (saves ~5+ minutes in Docker builds) - helpers.isDevelopment() && checker({ - typescript: true, - overlay: true - }) - ].filter(Boolean), + ], root: resolve(rootDir, 'app/client'),