diff --git a/.gitignore b/.gitignore index 2f9680a..b5ca5fa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ node_modules dist/ +module/ +logs/ .DS_Store diff --git a/AGENTS.md b/AGENTS.md index f52fbd7..74783e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,7 @@ This template provides: - Consistent layout components (header, sidebar, main content area) - Help modal system - Local development server with WebSocket support +- **Composer-ready bundle**: `npm run build:module` outputs `module/` for hosts that load `/simulations/{id}/content.html`, `simulation.css`, and `simulation.js` - Standardized file structure and naming conventions ## Quick Start @@ -18,16 +19,22 @@ This template provides: 1. **Customize the HTML template** (`client/index.html`): - Replace `` with your page title - Replace `` with your app name - - Add your main content at `` + - Replace the default `
` block with your layout (keep that `id` and `data-bespoke-sim-root` if you use `standalone.js` and composer hosts) + - Keep `client/content.html` in sync with the same inner markup for `build:module` - Add app-specific CSS links at `` - Add app-specific JavaScript at `` 2. **Create your application files**: - App-specific CSS (e.g., `my-app.css`) - - App-specific JavaScript (e.g., `my-app.js`) + - App-specific JavaScript (e.g., `my-app.js`); export `init(context)` from `simulation-app.js` for composer hosts - Help content (based on `help-content-template.html`) -3. **Start the development server**: +3. **Composer / multi-app host** (optional): + - Run `npm run build` for the full static app (`dist/`) and `npm run build:module` for the host bundle (`module/`) + - Serve the module tree with `IS_PRODUCTION=true SERVE_DIR=module PORT= node server.js` (each app on its own port when the host proxies `/simulations/{id}/…`) + - Match `SIM_ID` in `client/standalone.js` to the `id` in the host’s `simulations.json` + +4. **Start the development server**: ```bash npm start ``` @@ -93,17 +100,17 @@ See [BESPOKE-TEMPLATE.md](./BESPOKE-TEMPLATE.md). ``` client/ - ├── index.html # Main HTML template - ├── app.js # Application logic - ├── bespoke-template.css # Template-specific styles - ├── help-modal.js # Help modal system - ├── help-content-template.html # Help content template - └── design-system/ # CodeSignal Design System - ├── colors/ - ├── spacing/ - ├── typography/ - └── components/ -server.js # Development server + ├── index.html # Main HTML template + ├── app.js # Shell: help modal + WebSocket + ├── standalone.js # Standalone init(context) + /api/log + ├── simulation-app.js # Composer entry: export init(context) + ├── content.html # Fragment for composer host + ├── bespoke-simulation.css # Styles for fragment → module/simulation.css + ├── bespoke-template.css # Template layout + ├── help-content.html # Help body HTML + └── design-system/ # CodeSignal Design System (git submodule) +server.js # API + static (dist/ or module/ via SERVE_DIR) +vite.config.module.js # build:module → module/ ``` ## Notes for AI Agents diff --git a/BESPOKE-TEMPLATE.md b/BESPOKE-TEMPLATE.md index 888b2f4..72db642 100644 --- a/BESPOKE-TEMPLATE.md +++ b/BESPOKE-TEMPLATE.md @@ -3,7 +3,7 @@ This document provides precise implementation instructions for creating embedded applications using the Bespoke Simulation template. Follow these instructions exactly to ensure consistency across all applications. -NOTE: Never edit this `BESPOKE-TEMPLATE.md` file. Codebase changes should be reflected in the `AGENTS.md` file. +Keep this document aligned with template behavior; `AGENTS.md` summarizes the same conventions for contributors. ## Required Files Structure @@ -37,9 +37,10 @@ Every application should include these files in the following order: Replace with your application's display name (appears in header) Example: "Database Designer" or "Task Manager" - c) `` - Add your application's main content area - Example: `
` or `
` + c) Main content area + Replace the default `
` block with your layout. + If you use a composer host, keep the same inner markup in `client/content.html` (fragment the host injects). + Preserve `id="standalone-sim-mount"` when using `standalone.js` unless you update `simulation-app.js` accordingly. d) `` Add links to your application-specific CSS files @@ -49,7 +50,23 @@ Every application should include these files in the following order: Add links to your application-specific JavaScript files Example: `` -2. DO NOT modify the core structure (header, script loading order, etc.) +2. DO NOT modify the core structure (header, script loading order, etc.) without reviewing `app.js`, `standalone.js`, and host loaders. + +## Composer hosts (multi-app workspace) + +Composer-style hosts load each app from the same origin under `/simulations/{id}/`: + +1. `content.html` — HTML fragment injected into a `.sim-slot[data-sim-id="{id}"]` +2. `simulation.css` — styles for that fragment (from `bespoke-simulation.css` in this template) +3. `simulation.js` — ES module exporting `init(context)` (and optionally `onAction`, `onMessage`) + +**Build:** `npm run build:module` writes these files under `module/`. The Rollup build marks `design-system/*` as external so the host page supplies those assets once. + +**Serve for the host:** `IS_PRODUCTION=true SERVE_DIR=module PORT= node server.js` serves `module/` as static files. Use a distinct `PORT` per app when the host reverse-proxies to each repo. + +**Runtime contract:** `context` includes `config` (spread from the host registry, plus `basePath` like `/simulations/your-id`) and `emit(eventType, payload)` for telemetry. The template resolves DOM via `.sim-slot[data-sim-id]` + `[data-bespoke-sim-root]`; standalone mode uses `#standalone-sim-mount`. + +**Logging:** `POST /api/log` accepts `{ "entries": [...] }` and appends JSON lines to `logs/events.jsonl` (used by `standalone.js` and typical hosts). ## CSS Implementation diff --git a/README.md b/README.md index 31366de..75068ac 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ This directory contains a template for creating embedded applications that share a consistent design system and user experience. +Each app can run as a **normal static site** (`npm run build` → `dist/`) and as a **bundle for composer hosts** (`npm run build:module` → `module/`). Hosts fetch `/simulations/{id}/content.html`, `simulation.css`, and `simulation.js`; serve the module tree with `IS_PRODUCTION=true SERVE_DIR=module PORT= node server.js`. See `AGENTS.md` and `BESPOKE-TEMPLATE.md`. + ## Components ### 1. Design System Integration @@ -24,12 +26,8 @@ A base HTML template that includes: - Help modal integration - Proper CSS and JavaScript loading -### 4. `client/help-modal.js` -A dependency-free JavaScript module for the help modal system: -- Consistent modal behavior across all apps -- Keyboard navigation (ESC to close) -- Focus management -- Custom event system +### 4. `client/app.js` and `client/standalone.js` +Shell behavior (help modal via design system `Modal`, WebSocket) plus standalone `init(context)` wiring for the same contract composer hosts use. ### 5. `client/help-content-template.html` A template for creating consistent help content: @@ -52,7 +50,7 @@ A template for creating consistent help content: - `` - Your application title - `` - Your application name (appears in header) - `` - Any additional header elements - - `` - Your main content area + - Default `
` / `client/content.html` — your main UI (keep in sync for composer) - `` - Links to your app-specific CSS files - `` - Links to your app-specific JavaScript files diff --git a/client/bespoke-simulation.css b/client/bespoke-simulation.css new file mode 100644 index 0000000..567c5ef --- /dev/null +++ b/client/bespoke-simulation.css @@ -0,0 +1,12 @@ +/* Styles for simulation fragment (bundled as module/simulation.css) */ +.bespoke-sim-intro { + max-width: 36rem; +} + +.bespoke-sim-hint code { + font-size: 0.9em; +} + +.bespoke-sim-standalone-mount { + padding: var(--UI-Spacing-spacing-ml, 1rem); +} diff --git a/client/content.html b/client/content.html new file mode 100644 index 0000000..f62dc4d --- /dev/null +++ b/client/content.html @@ -0,0 +1,10 @@ + +
+
+

Your app

+

+ Replace this markup and extend simulation-app.js. Keep this file in sync with the standalone block in index.html when you use both modes. +

+ +
+
diff --git a/client/index.html b/client/index.html index 51d312a..8a3191b 100644 --- a/client/index.html +++ b/client/index.html @@ -24,22 +24,32 @@ +
-

APP NAME

+

- - + +
+
+

Your app

+

+ Replace this block when customizing; keep in sync with client/content.html for composer builds. +

+ +
+
+ diff --git a/client/simulation-app.js b/client/simulation-app.js new file mode 100644 index 0000000..e721db0 --- /dev/null +++ b/client/simulation-app.js @@ -0,0 +1,47 @@ +/** + * Simulation entry for composer hosts: exports init(context). + * Hosts load content.html, simulation.css, then import ./simulation.js + * (see composer template simulation-loader.js). + */ + +function escapeSelector(id) { + if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') { + return CSS.escape(id); + } + return id.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} + +function resolveRoot(context) { + const simId = context.config?.id; + if (simId) { + const slot = document.querySelector( + `.sim-slot[data-sim-id="${escapeSelector(simId)}"]` + ); + const inner = slot?.querySelector('[data-bespoke-sim-root]'); + if (inner) return inner; + if (slot) return slot; + } + return document.getElementById('standalone-sim-mount'); +} + +function bindDemo(root, emit) { + const btn = root.querySelector('#btn-sim-demo'); + if (!btn) return; + btn.addEventListener('click', () => { + emit('demo:click', { source: 'template' }); + }); +} + +export function init(context = {}) { + const emit = + typeof context.emit === 'function' + ? context.emit + : () => {}; + + const root = resolveRoot(context); + if (!root) { + console.warn('simulation-app: no mount root found'); + return; + } + bindDemo(root, emit); +} diff --git a/client/standalone.js b/client/standalone.js new file mode 100644 index 0000000..b75d684 --- /dev/null +++ b/client/standalone.js @@ -0,0 +1,43 @@ +/** + * Standalone page: same init(context) contract as composer hosts. + * Set SIM_ID to the id used in the host's simulations.json. + */ +import { init } from './simulation-app.js'; + +const SIM_ID = 'bespoke-app'; + +const logBuffer = []; +let flushTimer = null; + +function flushLogs() { + const entries = logBuffer.splice(0); + flushTimer = null; + if (entries.length === 0) return; + fetch('/api/log', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ entries }) + }).catch(err => console.error('Failed to flush logs:', err)); +} + +function pushLog(entry) { + logBuffer.push({ ...entry, ts: new Date().toISOString() }); + if (!flushTimer) flushTimer = setTimeout(flushLogs, 1000); +} + +const context = { + config: { id: SIM_ID, basePath: '' }, + emit: (eventType, payload = {}) => { + pushLog({ simId: SIM_ID, dir: 'event', type: eventType, payload }); + } +}; + +function boot() { + init(context); +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', boot); +} else { + boot(); +} diff --git a/package.json b/package.json index c6309b1..a6264df 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "start:dev": "concurrently \"npm run dev:vite\" \"npm run dev:api\"", "dev:vite": "vite", "dev:api": "PORT=3001 node server.js", - "build": "vite build" + "build": "vite build", + "build:module": "vite build --config vite.config.module.js" }, "keywords": [ "bespoke", diff --git a/server.js b/server.js index 9c3351e..44472aa 100644 --- a/server.js +++ b/server.js @@ -16,19 +16,21 @@ try { } const DIST_DIR = path.join(__dirname, 'dist'); -// Check if IS_PRODUCTION is set to true +const STATIC_DIR = process.env.SERVE_DIR + ? path.join(__dirname, process.env.SERVE_DIR) + : DIST_DIR; const isProduction = process.env.IS_PRODUCTION === 'true'; -// In production mode, dist directory must exist -if (isProduction && !fs.existsSync(DIST_DIR)) { - throw new Error(`Production mode enabled but dist directory does not exist: ${DIST_DIR}`); +if (isProduction && !fs.existsSync(STATIC_DIR)) { + throw new Error(`Production mode enabled but serve directory does not exist: ${STATIC_DIR}`); } -// Force port 3000 in production, otherwise use PORT environment variable or default to 3000 -const PORT = isProduction ? 3000 : (process.env.PORT || 3000); +const PORT = process.env.PORT || 3000; -// Track connected WebSocket clients const wsClients = new Set(); -// MIME types for different file extensions +const LOG_DIR = path.join(__dirname, 'logs'); +const LOG_FILE = path.join(LOG_DIR, 'events.jsonl'); +if (!fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive: true }); + const mimeTypes = { '.html': 'text/html', '.js': 'text/javascript', @@ -46,13 +48,11 @@ const mimeTypes = { '.eot': 'application/vnd.ms-fontobject' }; -// Get MIME type based on file extension function getMimeType(filePath) { const ext = path.extname(filePath).toLowerCase(); return mimeTypes[ext] || 'text/plain'; } -// Serve static files function serveFile(filePath, res) { fs.readFile(filePath, (err, data) => { if (err) { @@ -67,8 +67,45 @@ function serveFile(filePath, res) { }); } -// Handle POST requests +function readBody(req) { + return new Promise((resolve, reject) => { + let body = ''; + req.on('data', chunk => { body += chunk.toString(); }); + req.on('end', () => { + try { resolve(JSON.parse(body)); } + catch (e) { reject(e); } + }); + req.on('error', reject); + }); +} + +async function handleLogRequest(req, res) { + try { + const data = await readBody(req); + const entries = data.entries; + if (!Array.isArray(entries) || entries.length === 0) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'entries array is required' })); + return; + } + const lines = entries.map(e => JSON.stringify(e)).join('\n') + '\n'; + fs.appendFile(LOG_FILE, lines, (err) => { + if (err) console.error('Failed to write log:', err); + }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, count: entries.length })); + } catch (error) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + } +} + function handlePostRequest(req, res, parsedUrl) { + if (parsedUrl.pathname === '/api/log') { + handleLogRequest(req, res); + return; + } + if (parsedUrl.pathname === '/message') { let body = ''; @@ -87,7 +124,6 @@ function handlePostRequest(req, res, parsedUrl) { return; } - // Check if WebSocket is available if (!isWebSocketAvailable) { res.writeHead(503, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ @@ -97,7 +133,6 @@ function handlePostRequest(req, res, parsedUrl) { return; } - // Broadcast message to all connected WebSocket clients wsClients.forEach(client => { if (client.readyState === WebSocket.OPEN) { client.send(JSON.stringify({ type: 'message', message: message })); @@ -118,28 +153,22 @@ function handlePostRequest(req, res, parsedUrl) { } } -// Create HTTP server const server = http.createServer((req, res) => { const parsedUrl = url.parse(req.url, true); let pathName = parsedUrl.pathname === '/' ? '/index.html' : parsedUrl.pathname; - // Handle POST requests if (req.method === 'POST') { handlePostRequest(req, res, parsedUrl); return; } - // In production mode, serve static files from dist directory if (isProduction) { - // Strip leading slashes so path.join/resolve can't ignore DIST_DIR - let filePath = path.join(DIST_DIR, pathName.replace(/^\/+/, '')); + let filePath = path.join(STATIC_DIR, pathName.replace(/^\/+/, '')); - // Security check - prevent directory traversal - const resolvedDistDir = path.resolve(DIST_DIR); + const resolvedBaseDir = path.resolve(STATIC_DIR); const resolvedFilePath = path.resolve(filePath); - const relativePath = path.relative(resolvedDistDir, resolvedFilePath); + const relativePath = path.relative(resolvedBaseDir, resolvedFilePath); - // Reject if path tries to traverse outside the base directory if (relativePath.startsWith('..')) { res.writeHead(403, { 'Content-Type': 'text/plain' }); res.end('Forbidden'); @@ -148,16 +177,11 @@ const server = http.createServer((req, res) => { serveFile(filePath, res); } else { - // Development mode - static files are served by Vite res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('Not found (development mode - use Vite dev server `npm run start:dev`)'); } }); -// Create WebSocket server only if WebSocket is available -// Note: WebSocket upgrade handling is performed automatically by the ws library -// when attached to the HTTP server. The HTTP request handler should NOT send -// a response for upgrade requests - the ws library handles the upgrade internally. if (isWebSocketAvailable) { const wss = new WebSocket.Server({ server, @@ -180,11 +204,10 @@ if (isWebSocketAvailable) { }); } -// Start server server.listen(PORT, () => { console.log(`Server running at http://localhost:${PORT}`); if (isProduction) { - console.log(`Serving static files from: ${DIST_DIR}`); + console.log(`Serving static files from: ${STATIC_DIR}`); } else { console.log(`Development mode - static files served by Vite`); } @@ -196,7 +219,6 @@ server.listen(PORT, () => { console.log('Press Ctrl+C to stop the server'); }); -// Handle server errors server.on('error', (err) => { if (err.code === 'EADDRINUSE') { console.error(`Port ${PORT} is already in use. Please try a different port.`); @@ -206,7 +228,6 @@ server.on('error', (err) => { process.exit(1); }); -// Graceful shutdown process.on('SIGINT', () => { console.log('\nShutting down server...'); server.close(() => { diff --git a/vite.config.js b/vite.config.js index dd3ebd1..ae897fa 100644 --- a/vite.config.js +++ b/vite.config.js @@ -8,6 +8,10 @@ export default defineConfig({ allowedHosts: true, port: 3000, proxy: { + '/api': { + target: 'http://localhost:3001', + changeOrigin: true + }, '/message': { target: 'http://localhost:3001', changeOrigin: true diff --git a/vite.config.module.js b/vite.config.module.js new file mode 100644 index 0000000..22b47b4 --- /dev/null +++ b/vite.config.module.js @@ -0,0 +1,40 @@ +import { defineConfig } from 'vite'; +import { resolve } from 'path'; +import { copyFileSync, mkdirSync } from 'fs'; + +const copyModuleAssets = { + name: 'copy-module-assets', + closeBundle() { + const outDir = resolve(__dirname, 'module'); + mkdirSync(outDir, { recursive: true }); + copyFileSync( + resolve(__dirname, 'client/content.html'), + resolve(outDir, 'content.html') + ); + copyFileSync( + resolve(__dirname, 'client/bespoke-simulation.css'), + resolve(outDir, 'simulation.css') + ); + copyFileSync( + resolve(__dirname, 'client/help-content.html'), + resolve(outDir, 'help-content.html') + ); + } +}; + +export default defineConfig({ + root: './client', + plugins: [copyModuleAssets], + build: { + outDir: '../module', + emptyOutDir: true, + lib: { + entry: resolve(__dirname, 'client/simulation-app.js'), + formats: ['es'], + fileName: () => 'simulation.js' + }, + rollupOptions: { + external: [/design-system/] + } + } +});