Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
114 changes: 44 additions & 70 deletions src/plugin/uiPlugin.ts
Original file line number Diff line number Diff line change
@@ -1,61 +1,43 @@
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<string, string> = {
'.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 <base> tag so relative asset URLs resolve under /__observatory/. */
function injectBase(html: string): string {
return html.replace('<head>', `<head><base href="${ROUTE_BASE}/">`)
}

/**
* 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

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
},
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')
}
},
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)) {
Expand Down Expand Up @@ -87,6 +69,7 @@ export function uiPlugin(): Plugin {
return
})

// Serve the Observatory UI at /__observatory
server.middlewares.use((req, res, next) => {
const url = req.url ?? ''

Expand All @@ -95,53 +78,44 @@ 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 <base> tag injected)
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()
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()
// Serve the UI HTML for the base route
if (
assetPath === '' ||
assetPath === '/' ||
assetPath === '/index.html'
) {
const cssContent = readFileSync(uiCss, 'utf-8')
Comment thread
BAJ- marked this conversation as resolved.
const html = `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Reactoscope</title>
<style>${cssContent}</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/@id/__x00__${VIRTUAL_UI_ID}"></script>
</body>
</html>`
server
.transformIndexHtml(url, html)
.then((transformed) => {
res.writeHead(200, { 'Content-Type': 'text/html' })
res.end(transformed)
})
.catch(next)
return
}

const ext = extname(filePath)
const contentType = CONTENT_TYPES[ext] || 'application/octet-stream'

res.writeHead(200, { 'Content-Type': contentType })
res.end(readFileSync(filePath))
next()
})
},
}
Expand Down
23 changes: 22 additions & 1 deletion vite.config.ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '.',
Expand All @@ -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,
},
},
},
})
Loading