From 12cdf672c8365b3ba14cd930a94b82c2cac19d67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20A=2E=20Johansen?= Date: Tue, 7 Apr 2026 12:37:21 +0100 Subject: [PATCH 1/4] Externalize React from UI build and serve via virtual modules --- src/plugin/uiPlugin.ts | 114 ++++++++++++++++------------------------- vite.config.ui.ts | 23 ++++++++- 2 files changed, 67 insertions(+), 70 deletions(-) diff --git a/src/plugin/uiPlugin.ts b/src/plugin/uiPlugin.ts index b8f493b..669d902 100644 --- a/src/plugin/uiPlugin.ts +++ b/src/plugin/uiPlugin.ts @@ -1,61 +1,50 @@ -import { existsSync, readFileSync, statSync } from 'node:fs' -import { resolve, relative, isAbsolute, dirname, extname } from 'node:path' +import { existsSync, readFileSync } from 'node:fs' +import { resolve, dirname } from 'node:path' import { fileURLToPath } from 'node:url' import type { Plugin } from 'vite' const ROUTE_BASE = '/__observatory' -const CONTENT_TYPES: Record = { - '.html': 'text/html', - '.js': 'application/javascript', - '.css': 'text/css', - '.json': 'application/json', - '.svg': 'image/svg+xml', - '.png': 'image/png', - '.ico': 'image/x-icon', -} - -/** Inject a tag so relative asset URLs resolve under /__observatory/. */ -function injectBase(html: string): string { - return html.replace('', ``) -} - -/** - * Vite plugin that serves the pre-built Observatory UI from dist/client/. - * - * Only activates when the pre-built client directory exists (i.e., when - * reactoscope is installed as an npm package). During RO development, - * the normal Vite dev server handles the UI directly from source. - */ const VIRTUAL_RENDER_ID = 'virtual:observatory-render' const RESOLVED_RENDER_ID = '\0' + VIRTUAL_RENDER_ID +const VIRTUAL_UI_ID = 'virtual:observatory-ui' +const RESOLVED_UI_ID = '\0' + VIRTUAL_UI_ID + +const VIRTUAL_UI_CSS_ID = 'virtual:observatory-ui.css' +const RESOLVED_UI_CSS_ID = '\0' + VIRTUAL_UI_CSS_ID + export function uiPlugin(): Plugin { const selfDir = dirname(fileURLToPath(import.meta.url)) - // When built: dist/plugin.mjs → dist/client/ and dist/render/ - const clientDir = resolve(selfDir, 'client') const renderEntry = resolve(selfDir, 'render', 'entry.js') + const uiEntry = resolve(selfDir, 'client', 'observatory-ui.js') + const uiCss = resolve(selfDir, 'client', 'observatory-ui.css') return { name: 'observatory-ui', resolveId(id) { if (id === VIRTUAL_RENDER_ID) return RESOLVED_RENDER_ID + if (id === VIRTUAL_UI_ID) return RESOLVED_UI_ID + if (id === VIRTUAL_UI_CSS_ID) return RESOLVED_UI_CSS_ID }, load(id) { if (id === RESOLVED_RENDER_ID && existsSync(renderEntry)) { - // Return the pre-built render entry. Vite will transform the - // bare imports (react, react-dom) into optimized dep references. return readFileSync(renderEntry, 'utf-8') } + if (id === RESOLVED_UI_ID && existsSync(uiEntry)) { + return readFileSync(uiEntry, 'utf-8') + } + if (id === RESOLVED_UI_CSS_ID && existsSync(uiCss)) { + return readFileSync(uiCss, 'utf-8') + } }, configureServer(server) { - if (!existsSync(clientDir)) { - // No pre-built UI — we're in RO dev mode, let Vite handle everything + if (!existsSync(uiEntry)) { + // No pre-built UI — we're in dev mode, let Vite handle everything return } // Serve a virtual HTML page for the component iframe. - // /?render=&component=... loads ComponentRenderer via Vite's pipeline. server.middlewares.use((req, res, next) => { const url = req.url ?? '' if (!url.startsWith(ROUTE_BASE)) { @@ -87,6 +76,7 @@ export function uiPlugin(): Plugin { return }) + // Serve the Observatory UI at /__observatory server.middlewares.use((req, res, next) => { const url = req.url ?? '' @@ -95,53 +85,39 @@ export function uiPlugin(): Plugin { return } - // Strip the route base to get the asset path - let assetPath = url.slice(ROUTE_BASE.length) || '/index.html' - - // Strip query strings (e.g. ?component=...) + // Strip the route base and query string + let assetPath = url.slice(ROUTE_BASE.length) || '/' const queryIndex = assetPath.indexOf('?') if (queryIndex >= 0) { assetPath = assetPath.slice(0, queryIndex) } - // Serve index.html for the base route (with tag injected) + // Serve the UI HTML for the base route if (assetPath === '/' || assetPath === '/index.html') { - const indexPath = resolve(clientDir, 'index.html') - if (existsSync(indexPath)) { - res.writeHead(200, { 'Content-Type': 'text/html' }) - res.end(injectBase(readFileSync(indexPath, 'utf-8'))) - return - } - } - - const filePath = resolve(clientDir, assetPath.slice(1)) - - // Security: ensure resolved path is within clientDir - const relPath = relative(clientDir, filePath) - if (relPath.startsWith('..') || isAbsolute(relPath)) { - res.writeHead(403) - res.end() + const html = ` + + + + + Reactoscope + + + +
+ + +` + server + .transformIndexHtml(url, html) + .then((transformed) => { + res.writeHead(200, { 'Content-Type': 'text/html' }) + res.end(transformed) + }) + .catch(next) return } - if (!existsSync(filePath) || !statSync(filePath).isFile()) { - // SPA fallback: serve index.html for non-asset paths - const indexPath = resolve(clientDir, 'index.html') - if (existsSync(indexPath)) { - res.writeHead(200, { 'Content-Type': 'text/html' }) - res.end(injectBase(readFileSync(indexPath, 'utf-8'))) - return - } - res.writeHead(404) - res.end() - return - } - - const ext = extname(filePath) - const contentType = CONTENT_TYPES[ext] || 'application/octet-stream' - - res.writeHead(200, { 'Content-Type': contentType }) - res.end(readFileSync(filePath)) + next() }) }, } diff --git a/vite.config.ui.ts b/vite.config.ui.ts index cb03003..7d97b25 100644 --- a/vite.config.ui.ts +++ b/vite.config.ui.ts @@ -3,7 +3,11 @@ import react from '@vitejs/plugin-react' /** * Build config for the Observatory UI. - * Produces static assets in dist/client/ that the plugin serves at /__observatory. + * Produces a single JS + CSS bundle in dist/client/ that the plugin + * serves via a virtual module at /__observatory. + * + * React is externalized — bare imports are resolved at runtime by + * Vite's dev server from the host project's node_modules. */ export default defineConfig({ root: '.', @@ -12,5 +16,22 @@ export default defineConfig({ build: { outDir: 'dist/client', emptyOutDir: true, + lib: { + entry: 'src/main.tsx', + formats: ['es'], + fileName: 'observatory-ui', + }, + rollupOptions: { + external: [ + 'react', + 'react-dom', + 'react/jsx-runtime', + 'react/jsx-dev-runtime', + 'react-dom/client', + ], + output: { + inlineDynamicImports: true, + }, + }, }, }) From 67413cb941f049d6c98666e592c5005846b0078a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20A=2E=20Johansen?= Date: Tue, 7 Apr 2026 12:55:58 +0100 Subject: [PATCH 2/4] Fix UI route matching and inline CSS --- src/plugin/uiPlugin.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/plugin/uiPlugin.ts b/src/plugin/uiPlugin.ts index 669d902..6126d90 100644 --- a/src/plugin/uiPlugin.ts +++ b/src/plugin/uiPlugin.ts @@ -11,9 +11,6 @@ const RESOLVED_RENDER_ID = '\0' + VIRTUAL_RENDER_ID const VIRTUAL_UI_ID = 'virtual:observatory-ui' const RESOLVED_UI_ID = '\0' + VIRTUAL_UI_ID -const VIRTUAL_UI_CSS_ID = 'virtual:observatory-ui.css' -const RESOLVED_UI_CSS_ID = '\0' + VIRTUAL_UI_CSS_ID - export function uiPlugin(): Plugin { const selfDir = dirname(fileURLToPath(import.meta.url)) const renderEntry = resolve(selfDir, 'render', 'entry.js') @@ -25,7 +22,6 @@ export function uiPlugin(): Plugin { resolveId(id) { if (id === VIRTUAL_RENDER_ID) return RESOLVED_RENDER_ID if (id === VIRTUAL_UI_ID) return RESOLVED_UI_ID - if (id === VIRTUAL_UI_CSS_ID) return RESOLVED_UI_CSS_ID }, load(id) { if (id === RESOLVED_RENDER_ID && existsSync(renderEntry)) { @@ -34,9 +30,6 @@ export function uiPlugin(): Plugin { if (id === RESOLVED_UI_ID && existsSync(uiEntry)) { return readFileSync(uiEntry, 'utf-8') } - if (id === RESOLVED_UI_CSS_ID && existsSync(uiCss)) { - return readFileSync(uiCss, 'utf-8') - } }, configureServer(server) { if (!existsSync(uiEntry)) { @@ -93,14 +86,15 @@ export function uiPlugin(): Plugin { } // Serve the UI HTML for the base route - if (assetPath === '/' || assetPath === '/index.html') { + if (assetPath === '' || assetPath === '/' || assetPath === '/index.html') { + const cssContent = readFileSync(uiCss, 'utf-8') const html = ` Reactoscope - +
From 9a0de0ab069810b1bba8d7646d893135356e6302 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20A=2E=20Johansen?= Date: Tue, 7 Apr 2026 14:24:25 +0100 Subject: [PATCH 3/4] add changelog --- CHANGELOG.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c57757a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,23 @@ +# Changelog + +## [Unreleased] + +### Changed + +- Externalized React from all build outputs — React is now resolved from the host project's `node_modules` at runtime, reducing package size by 32% (146 KB → 99 KB compressed). +- Replaced static file server with virtual modules for serving the Observatory UI through Vite's transform pipeline. +- Inlined CSS directly into served HTML instead of referencing via a virtual module. + +## [0.1.0] - 2026-04-06 + +### Added + +- Initial release. +- CLI (`npx reactoscope path/to/Component.tsx`) and Vite plugin usage. +- Automatic TypeScript prop extraction and editable controls. +- Stress testing with render timing analysis. +- AI feedback via local Ollama integration. +- Visual snapshot diffing. + +[Unreleased]: https://github.com/BAJ-/reactoscope/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/BAJ-/reactoscope/releases/tag/v0.1.0 From 3a788dc90798bf5711240368e3b80b9aac2d4153 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20A=2E=20Johansen?= Date: Tue, 7 Apr 2026 14:25:29 +0100 Subject: [PATCH 4/4] prettier --- src/plugin/uiPlugin.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/plugin/uiPlugin.ts b/src/plugin/uiPlugin.ts index 6126d90..eb37305 100644 --- a/src/plugin/uiPlugin.ts +++ b/src/plugin/uiPlugin.ts @@ -86,7 +86,11 @@ export function uiPlugin(): Plugin { } // Serve the UI HTML for the base route - if (assetPath === '' || assetPath === '/' || assetPath === '/index.html') { + if ( + assetPath === '' || + assetPath === '/' || + assetPath === '/index.html' + ) { const cssContent = readFileSync(uiCss, 'utf-8') const html = `