From 0eed8b05f14c7fc9daf8ec3dec9b23ded559a632 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20A=2E=20Johansen?= Date: Sun, 5 Apr 2026 15:45:46 +0100 Subject: [PATCH 01/17] Split src/observatory into shared/, plugin/, and ui/ directories --- src/main.tsx | 4 ++-- .../plugins => plugin}/aiPlugin.ts | 2 +- .../hydrateProps.test.ts | 4 ++-- src/{observatory => plugin}/hydrateProps.ts | 6 ++--- .../plugins => plugin}/schemaPlugin.ts | 24 ++++--------------- .../plugins => plugin}/stressPlugin.ts | 8 +++---- src/{observatory => plugin}/stressRender.ts | 0 .../analyzeHealth.test.ts | 0 src/{observatory => shared}/analyzeHealth.ts | 0 src/{observatory => shared}/constants.ts | 3 +++ .../hydrateDescriptor.test.ts | 0 .../hydrateDescriptor.ts | 0 .../stressStats.test.ts | 0 src/{observatory => shared}/stressStats.ts | 0 src/shared/types.ts | 18 ++++++++++++++ src/{observatory => ui}/AIPanel.tsx | 0 src/{observatory => ui}/App.css | 0 src/{observatory => ui}/App.tsx | 6 ++--- src/{observatory => ui}/ComponentRenderer.tsx | 4 ++-- src/{observatory => ui}/ErrorBoundary.tsx | 0 src/{observatory => ui}/HealthPanel.tsx | 2 +- src/{observatory => ui}/PdiffModal.tsx | 0 src/{observatory => ui}/PropsPanel.tsx | 4 ++-- src/{observatory => ui}/ScenarioPanel.tsx | 0 src/{observatory => ui}/StressModal.tsx | 2 +- src/{observatory => ui}/TimelinePanel.tsx | 0 src/{observatory => ui}/VariantCard.tsx | 0 src/{observatory => ui}/ViewportControls.tsx | 2 +- src/{observatory => ui}/buildIframeSrc.ts | 0 src/{observatory => ui}/captureIframe.ts | 2 +- src/{observatory => ui}/generateProps.ts | 5 ++-- src/{observatory => ui}/pdiff.test.ts | 0 src/{observatory => ui}/pdiff.ts | 0 src/{observatory => ui}/resolveProps.ts | 6 ++--- src/{observatory => ui}/timelineTree.test.ts | 0 src/{observatory => ui}/timelineTree.ts | 2 +- src/{observatory => ui}/useAI.ts | 7 +++--- src/{observatory => ui}/usePdiff.ts | 2 +- .../usePinnedVariants.test.ts | 0 src/{observatory => ui}/usePinnedVariants.ts | 2 +- src/{observatory => ui}/useScenarios.test.ts | 0 src/{observatory => ui}/useScenarios.ts | 0 src/{observatory => ui}/useStress.ts | 4 ++-- src/{observatory => ui}/useTimeline.test.ts | 0 src/{observatory => ui}/useTimeline.ts | 0 vite.config.ts | 6 ++--- 46 files changed, 64 insertions(+), 61 deletions(-) rename src/{observatory/plugins => plugin}/aiPlugin.ts (98%) rename src/{observatory => plugin}/hydrateProps.test.ts (97%) rename src/{observatory => plugin}/hydrateProps.ts (88%) rename src/{observatory/plugins => plugin}/schemaPlugin.ts (94%) rename src/{observatory/plugins => plugin}/stressPlugin.ts (97%) rename src/{observatory => plugin}/stressRender.ts (100%) rename src/{observatory => shared}/analyzeHealth.test.ts (100%) rename src/{observatory => shared}/analyzeHealth.ts (100%) rename src/{observatory => shared}/constants.ts (91%) rename src/{observatory => shared}/hydrateDescriptor.test.ts (100%) rename src/{observatory => shared}/hydrateDescriptor.ts (100%) rename src/{observatory => shared}/stressStats.test.ts (100%) rename src/{observatory => shared}/stressStats.ts (100%) create mode 100644 src/shared/types.ts rename src/{observatory => ui}/AIPanel.tsx (100%) rename src/{observatory => ui}/App.css (100%) rename src/{observatory => ui}/App.tsx (98%) rename src/{observatory => ui}/ComponentRenderer.tsx (97%) rename src/{observatory => ui}/ErrorBoundary.tsx (100%) rename src/{observatory => ui}/HealthPanel.tsx (97%) rename src/{observatory => ui}/PdiffModal.tsx (100%) rename src/{observatory => ui}/PropsPanel.tsx (97%) rename src/{observatory => ui}/ScenarioPanel.tsx (100%) rename src/{observatory => ui}/StressModal.tsx (97%) rename src/{observatory => ui}/TimelinePanel.tsx (100%) rename src/{observatory => ui}/VariantCard.tsx (100%) rename src/{observatory => ui}/ViewportControls.tsx (98%) rename src/{observatory => ui}/buildIframeSrc.ts (100%) rename src/{observatory => ui}/captureIframe.ts (93%) rename src/{observatory => ui}/generateProps.ts (86%) rename src/{observatory => ui}/pdiff.test.ts (100%) rename src/{observatory => ui}/pdiff.ts (100%) rename src/{observatory => ui}/resolveProps.ts (93%) rename src/{observatory => ui}/timelineTree.test.ts (100%) rename src/{observatory => ui}/timelineTree.ts (98%) rename src/{observatory => ui}/useAI.ts (98%) rename src/{observatory => ui}/usePdiff.ts (98%) rename src/{observatory => ui}/usePinnedVariants.test.ts (100%) rename src/{observatory => ui}/usePinnedVariants.ts (98%) rename src/{observatory => ui}/useScenarios.test.ts (100%) rename src/{observatory => ui}/useScenarios.ts (100%) rename src/{observatory => ui}/useStress.ts (94%) rename src/{observatory => ui}/useTimeline.test.ts (100%) rename src/{observatory => ui}/useTimeline.ts (100%) diff --git a/src/main.tsx b/src/main.tsx index 0465cb7..c66a50e 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -8,14 +8,14 @@ async function mount() { if (isRenderMode) { const { ComponentRenderer } = - await import('./observatory/ComponentRenderer.tsx') + await import('./ui/ComponentRenderer.tsx') root.render( , ) } else { - const { default: App } = await import('./observatory/App.tsx') + const { default: App } = await import('./ui/App.tsx') root.render( diff --git a/src/observatory/plugins/aiPlugin.ts b/src/plugin/aiPlugin.ts similarity index 98% rename from src/observatory/plugins/aiPlugin.ts rename to src/plugin/aiPlugin.ts index 1700ad5..cf2cabc 100644 --- a/src/observatory/plugins/aiPlugin.ts +++ b/src/plugin/aiPlugin.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs' import { resolve, relative } from 'node:path' import type { Plugin } from 'vite' import type { IncomingMessage, ServerResponse } from 'node:http' -import { API_AI_MODELS, API_AI_CHAT } from '../constants' +import { API_AI_MODELS, API_AI_CHAT } from '../shared/constants' const OLLAMA_BASE = 'http://localhost:11434' const MAX_BODY_BYTES = 1_048_576 // 1 MB diff --git a/src/observatory/hydrateProps.test.ts b/src/plugin/hydrateProps.test.ts similarity index 97% rename from src/observatory/hydrateProps.test.ts rename to src/plugin/hydrateProps.test.ts index 8aa99fc..cc25670 100644 --- a/src/observatory/hydrateProps.test.ts +++ b/src/plugin/hydrateProps.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest' -import type { PropInfo } from './plugins/schemaPlugin' +import type { PropInfo } from '../shared/types' import { hydrateProps } from './hydrateProps' -import { UNSET } from './generateProps' +import { UNSET } from '../shared/constants' function makeProp(overrides: Partial & { name: string }): PropInfo { return { type: 'string', required: true, ...overrides } diff --git a/src/observatory/hydrateProps.ts b/src/plugin/hydrateProps.ts similarity index 88% rename from src/observatory/hydrateProps.ts rename to src/plugin/hydrateProps.ts index ab64725..4019c22 100644 --- a/src/observatory/hydrateProps.ts +++ b/src/plugin/hydrateProps.ts @@ -1,6 +1,6 @@ -import type { PropInfo } from './plugins/schemaPlugin' -import { UNSET } from './generateProps' -import { hydrateValue } from './hydrateDescriptor' +import type { PropInfo } from '../shared/types' +import { UNSET } from '../shared/constants' +import { hydrateValue } from '../shared/hydrateDescriptor' /** * Server-safe function prop hydration. diff --git a/src/observatory/plugins/schemaPlugin.ts b/src/plugin/schemaPlugin.ts similarity index 94% rename from src/observatory/plugins/schemaPlugin.ts rename to src/plugin/schemaPlugin.ts index 90074f5..b09cc37 100644 --- a/src/observatory/plugins/schemaPlugin.ts +++ b/src/plugin/schemaPlugin.ts @@ -1,26 +1,10 @@ import ts from 'typescript' import { resolve } from 'node:path' import type { Plugin } from 'vite' -import { API_SCHEMA, HMR_SCHEMA_UPDATE } from '../constants' - -export interface PropInfo { - name: string - type: - | 'string' - | 'number' - | 'boolean' - | 'function' - | 'enum' - | 'array' - | 'object' - | 'unknown' - required: boolean - enumValues?: string[] - /** Full TypeScript signature for function props, e.g. "(n: number) => string" */ - signature?: string - /** Serializable default return value for function props, derived from the return type */ - returnDefault?: unknown -} +import { API_SCHEMA, HMR_SCHEMA_UPDATE } from '../shared/constants' +import type { PropInfo } from '../shared/types' + +export type { PropInfo } export function extractProps( filePath: string, diff --git a/src/observatory/plugins/stressPlugin.ts b/src/plugin/stressPlugin.ts similarity index 97% rename from src/observatory/plugins/stressPlugin.ts rename to src/plugin/stressPlugin.ts index 28a6741..6734b6a 100644 --- a/src/observatory/plugins/stressPlugin.ts +++ b/src/plugin/stressPlugin.ts @@ -1,11 +1,11 @@ import { resolve, relative } from 'node:path' import type { Plugin, ViteDevServer } from 'vite' import type { IncomingMessage, ServerResponse } from 'node:http' -import { API_STRESS } from '../constants' -import { computeStats } from '../stressStats' -import type { StressResult } from '../analyzeHealth' +import { API_STRESS } from '../shared/constants' +import { computeStats } from '../shared/stressStats' +import type { StressResult } from '../shared/analyzeHealth' import { extractProps } from './schemaPlugin' -import { hydrateProps } from '../hydrateProps' +import { hydrateProps } from './hydrateProps' interface StressRequest { component: string diff --git a/src/observatory/stressRender.ts b/src/plugin/stressRender.ts similarity index 100% rename from src/observatory/stressRender.ts rename to src/plugin/stressRender.ts diff --git a/src/observatory/analyzeHealth.test.ts b/src/shared/analyzeHealth.test.ts similarity index 100% rename from src/observatory/analyzeHealth.test.ts rename to src/shared/analyzeHealth.test.ts diff --git a/src/observatory/analyzeHealth.ts b/src/shared/analyzeHealth.ts similarity index 100% rename from src/observatory/analyzeHealth.ts rename to src/shared/analyzeHealth.ts diff --git a/src/observatory/constants.ts b/src/shared/constants.ts similarity index 91% rename from src/observatory/constants.ts rename to src/shared/constants.ts index 26d268a..a5daeec 100644 --- a/src/observatory/constants.ts +++ b/src/shared/constants.ts @@ -21,3 +21,6 @@ export const API_AI_CHAT = '/api/ai/chat' /** ID of the wrapper element around the rendered component in the iframe. */ export const COMPONENT_ROOT_ID = 'observatory-component-root' + +/** Sentinel value for unset props. */ +export const UNSET = '__unset__' as const diff --git a/src/observatory/hydrateDescriptor.test.ts b/src/shared/hydrateDescriptor.test.ts similarity index 100% rename from src/observatory/hydrateDescriptor.test.ts rename to src/shared/hydrateDescriptor.test.ts diff --git a/src/observatory/hydrateDescriptor.ts b/src/shared/hydrateDescriptor.ts similarity index 100% rename from src/observatory/hydrateDescriptor.ts rename to src/shared/hydrateDescriptor.ts diff --git a/src/observatory/stressStats.test.ts b/src/shared/stressStats.test.ts similarity index 100% rename from src/observatory/stressStats.test.ts rename to src/shared/stressStats.test.ts diff --git a/src/observatory/stressStats.ts b/src/shared/stressStats.ts similarity index 100% rename from src/observatory/stressStats.ts rename to src/shared/stressStats.ts diff --git a/src/shared/types.ts b/src/shared/types.ts new file mode 100644 index 0000000..d6bebeb --- /dev/null +++ b/src/shared/types.ts @@ -0,0 +1,18 @@ +export interface PropInfo { + name: string + type: + | 'string' + | 'number' + | 'boolean' + | 'function' + | 'enum' + | 'array' + | 'object' + | 'unknown' + required: boolean + enumValues?: string[] + /** Full TypeScript signature for function props, e.g. "(n: number) => string" */ + signature?: string + /** Serializable default return value for function props, derived from the return type */ + returnDefault?: unknown +} diff --git a/src/observatory/AIPanel.tsx b/src/ui/AIPanel.tsx similarity index 100% rename from src/observatory/AIPanel.tsx rename to src/ui/AIPanel.tsx diff --git a/src/observatory/App.css b/src/ui/App.css similarity index 100% rename from src/observatory/App.css rename to src/ui/App.css diff --git a/src/observatory/App.tsx b/src/ui/App.tsx similarity index 98% rename from src/observatory/App.tsx rename to src/ui/App.tsx index 94ea9e2..919caa0 100644 --- a/src/observatory/App.tsx +++ b/src/ui/App.tsx @@ -1,9 +1,9 @@ import { useState, useEffect, useRef, useMemo, useCallback } from 'react' -import type { PropInfo } from './plugins/schemaPlugin' +import type { PropInfo } from '../shared/types' import { generateProps } from './generateProps' import { type SerializableProps, readPropsFromUrl } from './resolveProps' import { getMarkedSequence } from './timelineTree' -import { analyzeHealth, worstSeverity } from './analyzeHealth' +import { analyzeHealth, worstSeverity } from '../shared/analyzeHealth' import { PropsPanel } from './PropsPanel' import { ViewportControls } from './ViewportControls' import { TimelinePanel } from './TimelinePanel' @@ -18,7 +18,7 @@ import { useStress } from './useStress' import { usePinnedVariants } from './usePinnedVariants' import { useAI } from './useAI' import { AIPanel } from './AIPanel' -import { MSG_PROPS, HMR_SCHEMA_UPDATE, API_SCHEMA } from './constants' +import { MSG_PROPS, HMR_SCHEMA_UPDATE, API_SCHEMA } from '../shared/constants' import { buildIframeSrc } from './buildIframeSrc' import './App.css' diff --git a/src/observatory/ComponentRenderer.tsx b/src/ui/ComponentRenderer.tsx similarity index 97% rename from src/observatory/ComponentRenderer.tsx rename to src/ui/ComponentRenderer.tsx index 5b5e344..fd4f709 100644 --- a/src/observatory/ComponentRenderer.tsx +++ b/src/ui/ComponentRenderer.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import type { PropInfo } from './plugins/schemaPlugin' +import type { PropInfo } from '../shared/types' import { resolveProps, type SerializableProps, @@ -11,7 +11,7 @@ import { MSG_RENDERED, API_SCHEMA, COMPONENT_ROOT_ID, -} from './constants' +} from '../shared/constants' export function ComponentRenderer() { const params = new URLSearchParams(window.location.search) diff --git a/src/observatory/ErrorBoundary.tsx b/src/ui/ErrorBoundary.tsx similarity index 100% rename from src/observatory/ErrorBoundary.tsx rename to src/ui/ErrorBoundary.tsx diff --git a/src/observatory/HealthPanel.tsx b/src/ui/HealthPanel.tsx similarity index 97% rename from src/observatory/HealthPanel.tsx rename to src/ui/HealthPanel.tsx index 3053c90..0332272 100644 --- a/src/observatory/HealthPanel.tsx +++ b/src/ui/HealthPanel.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react' import type { StressRun } from './useStress' -import { analyzeHealth, worstSeverity, type Finding } from './analyzeHealth' +import { analyzeHealth, worstSeverity, type Finding } from '../shared/analyzeHealth' import { X, RefreshCw, diff --git a/src/observatory/PdiffModal.tsx b/src/ui/PdiffModal.tsx similarity index 100% rename from src/observatory/PdiffModal.tsx rename to src/ui/PdiffModal.tsx diff --git a/src/observatory/PropsPanel.tsx b/src/ui/PropsPanel.tsx similarity index 97% rename from src/observatory/PropsPanel.tsx rename to src/ui/PropsPanel.tsx index a110f21..76d3659 100644 --- a/src/observatory/PropsPanel.tsx +++ b/src/ui/PropsPanel.tsx @@ -1,5 +1,5 @@ -import type { PropInfo } from './plugins/schemaPlugin' -import { UNSET } from './generateProps' +import type { PropInfo } from '../shared/types' +import { UNSET } from '../shared/constants' import { functionBehaviorOptions, type SerializableProps } from './resolveProps' interface PropsPanelProps { diff --git a/src/observatory/ScenarioPanel.tsx b/src/ui/ScenarioPanel.tsx similarity index 100% rename from src/observatory/ScenarioPanel.tsx rename to src/ui/ScenarioPanel.tsx diff --git a/src/observatory/StressModal.tsx b/src/ui/StressModal.tsx similarity index 97% rename from src/observatory/StressModal.tsx rename to src/ui/StressModal.tsx index 3053c90..0332272 100644 --- a/src/observatory/StressModal.tsx +++ b/src/ui/StressModal.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react' import type { StressRun } from './useStress' -import { analyzeHealth, worstSeverity, type Finding } from './analyzeHealth' +import { analyzeHealth, worstSeverity, type Finding } from '../shared/analyzeHealth' import { X, RefreshCw, diff --git a/src/observatory/TimelinePanel.tsx b/src/ui/TimelinePanel.tsx similarity index 100% rename from src/observatory/TimelinePanel.tsx rename to src/ui/TimelinePanel.tsx diff --git a/src/observatory/VariantCard.tsx b/src/ui/VariantCard.tsx similarity index 100% rename from src/observatory/VariantCard.tsx rename to src/ui/VariantCard.tsx diff --git a/src/observatory/ViewportControls.tsx b/src/ui/ViewportControls.tsx similarity index 98% rename from src/observatory/ViewportControls.tsx rename to src/ui/ViewportControls.tsx index 3e9d2df..0d1bf9e 100644 --- a/src/observatory/ViewportControls.tsx +++ b/src/ui/ViewportControls.tsx @@ -1,4 +1,4 @@ -import type { Severity } from './analyzeHealth' +import type { Severity } from '../shared/analyzeHealth' import { Activity, Copy, Cpu } from 'react-feather' interface Viewport { diff --git a/src/observatory/buildIframeSrc.ts b/src/ui/buildIframeSrc.ts similarity index 100% rename from src/observatory/buildIframeSrc.ts rename to src/ui/buildIframeSrc.ts diff --git a/src/observatory/captureIframe.ts b/src/ui/captureIframe.ts similarity index 93% rename from src/observatory/captureIframe.ts rename to src/ui/captureIframe.ts index 274b087..66c3f70 100644 --- a/src/observatory/captureIframe.ts +++ b/src/ui/captureIframe.ts @@ -1,5 +1,5 @@ import html2canvas from 'html2canvas-pro' -import { COMPONENT_ROOT_ID } from './constants' +import { COMPONENT_ROOT_ID } from '../shared/constants' /** * Capture the rendered component inside a same-origin iframe as ImageData. diff --git a/src/observatory/generateProps.ts b/src/ui/generateProps.ts similarity index 86% rename from src/observatory/generateProps.ts rename to src/ui/generateProps.ts index 89e3477..8d8046d 100644 --- a/src/observatory/generateProps.ts +++ b/src/ui/generateProps.ts @@ -1,6 +1,5 @@ -import type { PropInfo } from './plugins/schemaPlugin' - -export const UNSET = '__unset__' as const +import type { PropInfo } from '../shared/types' +import { UNSET } from '../shared/constants' export function generateProps(props: PropInfo[]): Record { const result: Record = {} diff --git a/src/observatory/pdiff.test.ts b/src/ui/pdiff.test.ts similarity index 100% rename from src/observatory/pdiff.test.ts rename to src/ui/pdiff.test.ts diff --git a/src/observatory/pdiff.ts b/src/ui/pdiff.ts similarity index 100% rename from src/observatory/pdiff.ts rename to src/ui/pdiff.ts diff --git a/src/observatory/resolveProps.ts b/src/ui/resolveProps.ts similarity index 93% rename from src/observatory/resolveProps.ts rename to src/ui/resolveProps.ts index 35d9487..052b143 100644 --- a/src/observatory/resolveProps.ts +++ b/src/ui/resolveProps.ts @@ -1,6 +1,6 @@ -import type { PropInfo } from './plugins/schemaPlugin' -import { UNSET } from './generateProps' -import { hydrateValue } from './hydrateDescriptor' +import type { PropInfo } from '../shared/types' +import { UNSET } from '../shared/constants' +import { hydrateValue } from '../shared/hydrateDescriptor' type FunctionBehavior = 'noop' | 'log' diff --git a/src/observatory/timelineTree.test.ts b/src/ui/timelineTree.test.ts similarity index 100% rename from src/observatory/timelineTree.test.ts rename to src/ui/timelineTree.test.ts diff --git a/src/observatory/timelineTree.ts b/src/ui/timelineTree.ts similarity index 98% rename from src/observatory/timelineTree.ts rename to src/ui/timelineTree.ts index 8b3ffe5..3abff09 100644 --- a/src/observatory/timelineTree.ts +++ b/src/ui/timelineTree.ts @@ -1,5 +1,5 @@ import type { SerializableProps } from './resolveProps' -import { UNSET } from './generateProps' +import { UNSET } from '../shared/constants' export function getNodeLabel( node: TimelineNode, diff --git a/src/observatory/useAI.ts b/src/ui/useAI.ts similarity index 98% rename from src/observatory/useAI.ts rename to src/ui/useAI.ts index 4c0fc02..3cfbd71 100644 --- a/src/observatory/useAI.ts +++ b/src/ui/useAI.ts @@ -1,9 +1,8 @@ import { useState, useCallback, useRef, useEffect } from 'react' -import type { PropInfo } from './plugins/schemaPlugin' -import type { StressResult } from './analyzeHealth' +import type { PropInfo } from '../shared/types' +import type { StressResult } from '../shared/analyzeHealth' import type { SerializableProps } from './resolveProps' -import { UNSET } from './generateProps' -import { API_AI_MODELS, API_AI_CHAT } from './constants' +import { UNSET, API_AI_MODELS, API_AI_CHAT } from '../shared/constants' export interface AIModel { name: string diff --git a/src/observatory/usePdiff.ts b/src/ui/usePdiff.ts similarity index 98% rename from src/observatory/usePdiff.ts rename to src/ui/usePdiff.ts index b9fdcb8..b8faa51 100644 --- a/src/observatory/usePdiff.ts +++ b/src/ui/usePdiff.ts @@ -2,7 +2,7 @@ import { useState, useCallback, useRef, useEffect } from 'react' import type { Scenario } from './useScenarios' import { compareSnapshots } from './pdiff' import { captureIframe } from './captureIframe' -import { MSG_PROPS, MSG_RENDERED } from './constants' +import { MSG_PROPS, MSG_RENDERED } from '../shared/constants' export interface StepPairDiff { beforeUrl: string diff --git a/src/observatory/usePinnedVariants.test.ts b/src/ui/usePinnedVariants.test.ts similarity index 100% rename from src/observatory/usePinnedVariants.test.ts rename to src/ui/usePinnedVariants.test.ts diff --git a/src/observatory/usePinnedVariants.ts b/src/ui/usePinnedVariants.ts similarity index 98% rename from src/observatory/usePinnedVariants.ts rename to src/ui/usePinnedVariants.ts index d165921..33d99ad 100644 --- a/src/observatory/usePinnedVariants.ts +++ b/src/ui/usePinnedVariants.ts @@ -1,6 +1,6 @@ import { useState, useCallback } from 'react' import type { SerializableProps } from './resolveProps' -import { UNSET } from './generateProps' +import { UNSET } from '../shared/constants' export interface PinnedVariant { id: string diff --git a/src/observatory/useScenarios.test.ts b/src/ui/useScenarios.test.ts similarity index 100% rename from src/observatory/useScenarios.test.ts rename to src/ui/useScenarios.test.ts diff --git a/src/observatory/useScenarios.ts b/src/ui/useScenarios.ts similarity index 100% rename from src/observatory/useScenarios.ts rename to src/ui/useScenarios.ts diff --git a/src/observatory/useStress.ts b/src/ui/useStress.ts similarity index 94% rename from src/observatory/useStress.ts rename to src/ui/useStress.ts index 744acb9..e8b2b04 100644 --- a/src/observatory/useStress.ts +++ b/src/ui/useStress.ts @@ -1,7 +1,7 @@ import { useState, useCallback, useRef } from 'react' -import type { StressResult } from './analyzeHealth' +import type { StressResult } from '../shared/analyzeHealth' import type { SerializableProps } from './resolveProps' -import { API_STRESS } from './constants' +import { API_STRESS } from '../shared/constants' export interface StressRun { running: boolean diff --git a/src/observatory/useTimeline.test.ts b/src/ui/useTimeline.test.ts similarity index 100% rename from src/observatory/useTimeline.test.ts rename to src/ui/useTimeline.test.ts diff --git a/src/observatory/useTimeline.ts b/src/ui/useTimeline.ts similarity index 100% rename from src/observatory/useTimeline.ts rename to src/ui/useTimeline.ts diff --git a/vite.config.ts b/vite.config.ts index 53d3b7e..530f13f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,9 +1,9 @@ /// import { defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react' -import { schemaPlugin } from './src/observatory/plugins/schemaPlugin' -import { stressPlugin } from './src/observatory/plugins/stressPlugin' -import { aiPlugin } from './src/observatory/plugins/aiPlugin' +import { schemaPlugin } from './src/plugin/schemaPlugin' +import { stressPlugin } from './src/plugin/stressPlugin' +import { aiPlugin } from './src/plugin/aiPlugin' // https://vite.dev/config/ export default defineConfig({ From 6c1a59752bc3ff8f56073fe7eb0bb443c3e96d9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20A=2E=20Johansen?= Date: Sun, 5 Apr 2026 15:47:46 +0100 Subject: [PATCH 02/17] prettier --- src/main.tsx | 3 +-- src/ui/HealthPanel.tsx | 6 +++++- src/ui/StressModal.tsx | 6 +++++- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/main.tsx b/src/main.tsx index c66a50e..1fd512e 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -7,8 +7,7 @@ async function mount() { const root = createRoot(document.getElementById('root')!) if (isRenderMode) { - const { ComponentRenderer } = - await import('./ui/ComponentRenderer.tsx') + const { ComponentRenderer } = await import('./ui/ComponentRenderer.tsx') root.render( diff --git a/src/ui/HealthPanel.tsx b/src/ui/HealthPanel.tsx index 0332272..86186db 100644 --- a/src/ui/HealthPanel.tsx +++ b/src/ui/HealthPanel.tsx @@ -1,6 +1,10 @@ import { useMemo } from 'react' import type { StressRun } from './useStress' -import { analyzeHealth, worstSeverity, type Finding } from '../shared/analyzeHealth' +import { + analyzeHealth, + worstSeverity, + type Finding, +} from '../shared/analyzeHealth' import { X, RefreshCw, diff --git a/src/ui/StressModal.tsx b/src/ui/StressModal.tsx index 0332272..86186db 100644 --- a/src/ui/StressModal.tsx +++ b/src/ui/StressModal.tsx @@ -1,6 +1,10 @@ import { useMemo } from 'react' import type { StressRun } from './useStress' -import { analyzeHealth, worstSeverity, type Finding } from '../shared/analyzeHealth' +import { + analyzeHealth, + worstSeverity, + type Finding, +} from '../shared/analyzeHealth' import { X, RefreshCw, From 3edd34d3cf516e699e4969f515ba0b3bb73a6c5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20A=2E=20Johansen?= Date: Sun, 5 Apr 2026 15:53:04 +0100 Subject: [PATCH 03/17] Add composite observatory() plugin entry point --- src/plugin/index.ts | 27 +++++++++++++++++++++++++++ vite.config.ts | 6 ++---- 2 files changed, 29 insertions(+), 4 deletions(-) create mode 100644 src/plugin/index.ts diff --git a/src/plugin/index.ts b/src/plugin/index.ts new file mode 100644 index 0000000..f4433b9 --- /dev/null +++ b/src/plugin/index.ts @@ -0,0 +1,27 @@ +import type { Plugin } from 'vite' +import { schemaPlugin } from './schemaPlugin' +import { stressPlugin } from './stressPlugin' +import { aiPlugin } from './aiPlugin' + +export interface ObservatoryOptions { + /** Ollama API base URL (default: "http://localhost:11434") */ + ollamaUrl?: string +} + +/** + * Create the React Observatory Vite plugin array. + * + * Usage: + * ```ts + * import { observatory } from 'react-observatory' + * export default defineConfig({ + * plugins: [react(), ...observatory()] + * }) + * ``` + */ +export function observatory(options?: ObservatoryOptions): Plugin[] { + void options // wired in Step 7 + return [schemaPlugin(), stressPlugin(), aiPlugin()] +} + +export type { PropInfo } from '../shared/types' diff --git a/vite.config.ts b/vite.config.ts index 530f13f..5c59bf5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,13 +1,11 @@ /// import { defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react' -import { schemaPlugin } from './src/plugin/schemaPlugin' -import { stressPlugin } from './src/plugin/stressPlugin' -import { aiPlugin } from './src/plugin/aiPlugin' +import { observatory } from './src/plugin' // https://vite.dev/config/ export default defineConfig({ - plugins: [react(), schemaPlugin(), stressPlugin(), aiPlugin()], + plugins: [react(), ...observatory()], test: { environment: 'jsdom', globals: true, From b422fc632817c414d8f5b0fadef71e0b39ba71b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20A=2E=20Johansen?= Date: Sun, 5 Apr 2026 16:01:50 +0100 Subject: [PATCH 04/17] Add dual build: tsdown for plugin, Vite SPA for UI, and .nvmrc --- .nvmrc | 1 + package-lock.json | 461 +++++++++++++++++++++++++++++++++++++++++++++ package.json | 4 + tsconfig.node.json | 2 +- tsdown.config.ts | 12 ++ vite.config.ui.ts | 16 ++ 6 files changed, 495 insertions(+), 1 deletion(-) create mode 100644 .nvmrc create mode 100644 tsdown.config.ts create mode 100644 vite.config.ui.ts diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/package-lock.json b/package-lock.json index 10a9758..aadd81d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "globals": "^17.4.0", "jsdom": "^29.0.1", "prettier": "^3.8.1", + "tsdown": "^0.21.7", "typescript": "~5.9.3", "typescript-eslint": "^8.57.0", "vite": "^8.0.1", @@ -842,6 +843,19 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@quansync/fs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@quansync/fs/-/fs-1.0.0.tgz", + "integrity": "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "quansync": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-rc.12", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", @@ -1231,6 +1245,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsesc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz", + "integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1769,6 +1790,16 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/ansis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz", + "integrity": "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -1796,6 +1827,74 @@ "node": ">=12" } }, + "node_modules/ast-kit": { + "version": "3.0.0-beta.1", + "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-3.0.0-beta.1.tgz", + "integrity": "sha512-trmleAnZ2PxN/loHWVhhx1qeOHSRXq4TDsBBxq3GqeJitfk3+jTQ+v/C1km/KYq9M7wKqCewMh+/NAvVH7m+bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^8.0.0-beta.4", + "estree-walker": "^3.0.3", + "pathe": "^2.0.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/ast-kit/node_modules/@babel/helper-string-parser": { + "version": "8.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0-rc.3.tgz", + "integrity": "sha512-AmwWFx1m8G/a5cXkxLxTiWl+YEoWuoFLUCwqMlNuWO1tqAYITQAbCRPUkyBHv1VOFgfjVOqEj6L3u15J5ZCzTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/ast-kit/node_modules/@babel/helper-validator-identifier": { + "version": "8.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.0-rc.3.tgz", + "integrity": "sha512-8AWCJ2VJJyDFlGBep5GpaaQ9AAaE/FjAcrqI7jyssYhtL7WGV0DOKpJsQqM037xDbpRLHXsY8TwU7zDma7coOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/ast-kit/node_modules/@babel/parser": { + "version": "8.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.0-rc.3.tgz", + "integrity": "sha512-B20dvP3MfNc/XS5KKCHy/oyWl5IA6Cn9YjXRdDlCjNmUFrjvLXMNUfQq/QUy9fnG2gYkKKcrto2YaF9B32ToOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.0-rc.3" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/ast-kit/node_modules/@babel/types": { + "version": "8.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.0-rc.3.tgz", + "integrity": "sha512-mOm5ZrYmphGfqVWoH5YYMTITb3cDXsFgmvFlvkvWDMsR9X8RFnt7a0Wb6yNIdoFsiMO9WjYLq+U/FMtqIYAF8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^8.0.0-rc.3", + "@babel/helper-validator-identifier": "^8.0.0-rc.3" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1835,6 +1934,16 @@ "require-from-string": "^2.0.2" } }, + "node_modules/birpc": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-4.0.0.tgz", + "integrity": "sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/brace-expansion": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", @@ -1880,6 +1989,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cac": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/cac/-/cac-7.0.0.tgz", + "integrity": "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -2070,6 +2189,13 @@ "dev": true, "license": "MIT" }, + "node_modules/defu": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.6.tgz", + "integrity": "sha512-f8mefEW4WIVg4LckePx3mALjQSPQgFlg9U8yaPdlsbdYcHQyj9n2zL2LJEA52smeYxOvmd/nB7TpMtHGMTHcug==", + "dev": true, + "license": "MIT" + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -2098,6 +2224,27 @@ "license": "MIT", "peer": true }, + "node_modules/dts-resolver": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/dts-resolver/-/dts-resolver-2.1.3.tgz", + "integrity": "sha512-bihc7jPC90VrosXNzK0LTE2cuLP6jr0Ro8jk+kMugHReJVLIpHz/xadeq3MhuwyO4TD4OA3L1Q8pBBFRc08Tsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + }, + "peerDependencies": { + "oxc-resolver": ">=11.0.0" + }, + "peerDependenciesMeta": { + "oxc-resolver": { + "optional": true + } + } + }, "node_modules/electron-to-chromium": { "version": "1.5.328", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.328.tgz", @@ -2105,6 +2252,16 @@ "dev": true, "license": "ISC" }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/entities": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", @@ -2467,6 +2624,19 @@ "node": ">=6.9.0" } }, + "node_modules/get-tsconfig": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2520,6 +2690,13 @@ "hermes-estree": "0.25.1" } }, + "node_modules/hookable": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-6.1.0.tgz", + "integrity": "sha512-ZoKZSJgu8voGK2geJS+6YtYjvIzu9AOM/KZXsBxr83uhLL++e9pEv/dlgwgy3dvHg06kTz6JOh1hk3C8Ceiymw==", + "dev": true, + "license": "MIT" + }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -2573,6 +2750,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/import-without-cache": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/import-without-cache/-/import-without-cache-0.2.5.tgz", + "integrity": "sha512-B6Lc2s6yApwnD2/pMzFh/d5AVjdsDXjgkeJ766FmFuJELIGHNycKRj+l3A39yZPM4CchqNCB4RITEAYB1KUM6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -3423,6 +3613,23 @@ "node": ">=6" } }, + "node_modules/quansync": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-1.0.0.tgz", + "integrity": "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -3498,6 +3705,16 @@ "node": ">=4" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/rolldown": { "version": "1.0.0-rc.12", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", @@ -3532,6 +3749,120 @@ "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" } }, + "node_modules/rolldown-plugin-dts": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/rolldown-plugin-dts/-/rolldown-plugin-dts-0.23.2.tgz", + "integrity": "sha512-PbSqLawLgZBGcOGT3yqWBGn4cX+wh2nt5FuBGdcMHyOhoukmjbhYAl8NT9sE4U38Cm9tqLOIQeOrvzeayM0DLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/generator": "8.0.0-rc.3", + "@babel/helper-validator-identifier": "8.0.0-rc.3", + "@babel/parser": "8.0.0-rc.3", + "@babel/types": "8.0.0-rc.3", + "ast-kit": "^3.0.0-beta.1", + "birpc": "^4.0.0", + "dts-resolver": "^2.1.3", + "get-tsconfig": "^4.13.7", + "obug": "^2.1.1", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + }, + "peerDependencies": { + "@ts-macro/tsc": "^0.3.6", + "@typescript/native-preview": ">=7.0.0-dev.20260325.1", + "rolldown": "^1.0.0-rc.12", + "typescript": "^5.0.0 || ^6.0.0", + "vue-tsc": "~3.2.0" + }, + "peerDependenciesMeta": { + "@ts-macro/tsc": { + "optional": true + }, + "@typescript/native-preview": { + "optional": true + }, + "typescript": { + "optional": true + }, + "vue-tsc": { + "optional": true + } + } + }, + "node_modules/rolldown-plugin-dts/node_modules/@babel/generator": { + "version": "8.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0-rc.3.tgz", + "integrity": "sha512-em37/13/nR320G4jab/nIIHZgc2Wz2y/D39lxnTyxB4/D/omPQncl/lSdlnJY1OhQcRGugTSIF2l/69o31C9dA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^8.0.0-rc.3", + "@babel/types": "^8.0.0-rc.3", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "@types/jsesc": "^2.5.0", + "jsesc": "^3.0.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/rolldown-plugin-dts/node_modules/@babel/helper-string-parser": { + "version": "8.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0-rc.3.tgz", + "integrity": "sha512-AmwWFx1m8G/a5cXkxLxTiWl+YEoWuoFLUCwqMlNuWO1tqAYITQAbCRPUkyBHv1VOFgfjVOqEj6L3u15J5ZCzTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/rolldown-plugin-dts/node_modules/@babel/helper-validator-identifier": { + "version": "8.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.0-rc.3.tgz", + "integrity": "sha512-8AWCJ2VJJyDFlGBep5GpaaQ9AAaE/FjAcrqI7jyssYhtL7WGV0DOKpJsQqM037xDbpRLHXsY8TwU7zDma7coOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/rolldown-plugin-dts/node_modules/@babel/parser": { + "version": "8.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.0-rc.3.tgz", + "integrity": "sha512-B20dvP3MfNc/XS5KKCHy/oyWl5IA6Cn9YjXRdDlCjNmUFrjvLXMNUfQq/QUy9fnG2gYkKKcrto2YaF9B32ToOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.0-rc.3" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/rolldown-plugin-dts/node_modules/@babel/types": { + "version": "8.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.0-rc.3.tgz", + "integrity": "sha512-mOm5ZrYmphGfqVWoH5YYMTITb3cDXsFgmvFlvkvWDMsR9X8RFnt7a0Wb6yNIdoFsiMO9WjYLq+U/FMtqIYAF8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^8.0.0-rc.3", + "@babel/helper-validator-identifier": "^8.0.0-rc.3" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/rolldown/node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.12", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", @@ -3767,6 +4098,16 @@ "node": ">=20" } }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -3780,6 +4121,85 @@ "typescript": ">=4.8.4" } }, + "node_modules/tsdown": { + "version": "0.21.7", + "resolved": "https://registry.npmjs.org/tsdown/-/tsdown-0.21.7.tgz", + "integrity": "sha512-ukKIxKQzngkWvOYJAyptudclkm4VQqbjq+9HF5K5qDO8GJsYtMh8gIRwicbnZEnvFPr6mquFwYAVZ8JKt3rY2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansis": "^4.2.0", + "cac": "^7.0.0", + "defu": "^6.1.4", + "empathic": "^2.0.0", + "hookable": "^6.1.0", + "import-without-cache": "^0.2.5", + "obug": "^2.1.1", + "picomatch": "^4.0.4", + "rolldown": "1.0.0-rc.12", + "rolldown-plugin-dts": "^0.23.2", + "semver": "^7.7.4", + "tinyexec": "^1.0.4", + "tinyglobby": "^0.2.15", + "tree-kill": "^1.2.2", + "unconfig-core": "^7.5.0", + "unrun": "^0.2.34" + }, + "bin": { + "tsdown": "dist/run.mjs" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + }, + "peerDependencies": { + "@arethetypeswrong/core": "^0.18.1", + "@tsdown/css": "0.21.7", + "@tsdown/exe": "0.21.7", + "@vitejs/devtools": "*", + "publint": "^0.3.0", + "typescript": "^5.0.0 || ^6.0.0", + "unplugin-unused": "^0.5.0" + }, + "peerDependenciesMeta": { + "@arethetypeswrong/core": { + "optional": true + }, + "@tsdown/css": { + "optional": true + }, + "@tsdown/exe": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "publint": { + "optional": true + }, + "typescript": { + "optional": true + }, + "unplugin-unused": { + "optional": true + } + } + }, + "node_modules/tsdown/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -3839,6 +4259,20 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/unconfig-core": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/unconfig-core/-/unconfig-core-7.5.0.tgz", + "integrity": "sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@quansync/fs": "^1.0.0", + "quansync": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/undici": { "version": "7.24.6", "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.6.tgz", @@ -3856,6 +4290,33 @@ "dev": true, "license": "MIT" }, + "node_modules/unrun": { + "version": "0.2.34", + "resolved": "https://registry.npmjs.org/unrun/-/unrun-0.2.34.tgz", + "integrity": "sha512-LyaghRBR++r7svhDK6tnDz2XaYHWdneBOA0jbS8wnRsHerI9MFljX4fIiTgbbNbEVzZ0C9P1OjWLLe1OqoaaEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "rolldown": "1.0.0-rc.12" + }, + "bin": { + "unrun": "dist/cli.mjs" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/Gugustinette" + }, + "peerDependencies": { + "synckit": "^0.11.11" + }, + "peerDependenciesMeta": { + "synckit": { + "optional": true + } + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", diff --git a/package.json b/package.json index b1b4b7b..a8ccb20 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,9 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", + "build:plugin": "tsdown", + "build:ui": "vite build --config vite.config.ui.ts", + "build:package": "npm run build:plugin && npm run build:ui", "typecheck": "tsc -b --noEmit", "lint": "eslint .", "test": "vitest run", @@ -34,6 +37,7 @@ "globals": "^17.4.0", "jsdom": "^29.0.1", "prettier": "^3.8.1", + "tsdown": "^0.21.7", "typescript": "~5.9.3", "typescript-eslint": "^8.57.0", "vite": "^8.0.1", diff --git a/tsconfig.node.json b/tsconfig.node.json index 8a67f62..c25c54a 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -22,5 +22,5 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["vite.config.ts"] + "include": ["vite.config.ts", "vite.config.ui.ts", "tsdown.config.ts"] } diff --git a/tsdown.config.ts b/tsdown.config.ts new file mode 100644 index 0000000..dd7688a --- /dev/null +++ b/tsdown.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig({ + entry: { plugin: './src/plugin/index.ts' }, + outDir: './dist', + format: 'esm', + platform: 'node', + target: 'node20', + dts: { build: true }, + clean: true, + external: ['typescript', 'vite'], +}) diff --git a/vite.config.ui.ts b/vite.config.ui.ts new file mode 100644 index 0000000..cb03003 --- /dev/null +++ b/vite.config.ui.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +/** + * Build config for the Observatory UI. + * Produces static assets in dist/client/ that the plugin serves at /__observatory. + */ +export default defineConfig({ + root: '.', + base: './', + plugins: [react()], + build: { + outDir: 'dist/client', + emptyOutDir: true, + }, +}) From 25c7e729b192e3940d9c191dc582b27de711df0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20A=2E=20Johansen?= Date: Sun, 5 Apr 2026 16:09:48 +0100 Subject: [PATCH 05/17] Add UI serving plugin for /__observatory route --- src/plugin/index.ts | 3 +- src/plugin/uiPlugin.ts | 91 ++++++++++++++++++++++++++++++++++++++++++ tsdown.config.ts | 1 - 3 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 src/plugin/uiPlugin.ts diff --git a/src/plugin/index.ts b/src/plugin/index.ts index f4433b9..e68419a 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -2,6 +2,7 @@ import type { Plugin } from 'vite' import { schemaPlugin } from './schemaPlugin' import { stressPlugin } from './stressPlugin' import { aiPlugin } from './aiPlugin' +import { uiPlugin } from './uiPlugin' export interface ObservatoryOptions { /** Ollama API base URL (default: "http://localhost:11434") */ @@ -21,7 +22,7 @@ export interface ObservatoryOptions { */ export function observatory(options?: ObservatoryOptions): Plugin[] { void options // wired in Step 7 - return [schemaPlugin(), stressPlugin(), aiPlugin()] + return [uiPlugin(), schemaPlugin(), stressPlugin(), aiPlugin()] } export type { PropInfo } from '../shared/types' diff --git a/src/plugin/uiPlugin.ts b/src/plugin/uiPlugin.ts new file mode 100644 index 0000000..95f2ecc --- /dev/null +++ b/src/plugin/uiPlugin.ts @@ -0,0 +1,91 @@ +import { existsSync, readFileSync } from 'node:fs' +import { resolve, dirname, extname } 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', +} + +/** + * Vite plugin that serves the pre-built Observatory UI from dist/client/. + * + * Only activates when the pre-built client directory exists (i.e., when + * react-observatory is installed as an npm package). During RO development, + * the normal Vite dev server handles the UI directly from source. + */ +export function uiPlugin(): Plugin { + const selfDir = dirname(fileURLToPath(import.meta.url)) + // When built: dist/plugin.mjs → resolve dist/client/ + // When running from source: src/plugin/ → resolve ../../dist/client/ + const clientDir = resolve(selfDir, 'client') + + return { + name: 'observatory-ui', + configureServer(server) { + if (!existsSync(clientDir)) { + // No pre-built UI — we're in RO dev mode, let Vite handle everything + return + } + + server.middlewares.use((req, res, next) => { + const url = req.url ?? '' + + if (!url.startsWith(ROUTE_BASE)) { + next() + 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=...) + const queryIndex = assetPath.indexOf('?') + if (queryIndex >= 0) { + assetPath = assetPath.slice(0, queryIndex) + } + + // Serve index.html for the base route + if (assetPath === '/' || assetPath === '/index.html') { + assetPath = '/index.html' + } + + const filePath = resolve(clientDir, assetPath.slice(1)) + + // Security: ensure resolved path is within clientDir + if (!filePath.startsWith(clientDir)) { + res.writeHead(403) + res.end() + return + } + + if (!existsSync(filePath)) { + // 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(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)) + }) + }, + } +} diff --git a/tsdown.config.ts b/tsdown.config.ts index dd7688a..9bb19ac 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -7,6 +7,5 @@ export default defineConfig({ platform: 'node', target: 'node20', dts: { build: true }, - clean: true, external: ['typescript', 'vite'], }) From 5b953c24c3f98b2b403a2d3598a25a53b98532ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20A=2E=20Johansen?= Date: Sun, 5 Apr 2026 16:24:10 +0100 Subject: [PATCH 06/17] Update package.json for npm publishing --- package-lock.json | 144 ++++++++++++++++++++++++++-------------------- package.json | 57 ++++++++++++++---- 2 files changed, 129 insertions(+), 72 deletions(-) diff --git a/package-lock.json b/package-lock.json index aadd81d..9d6e083 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,20 @@ { "name": "react-observatory", - "version": "0.0.0", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "react-observatory", - "version": "0.0.0", + "version": "0.1.0", + "license": "MIT", "dependencies": { - "html2canvas-pro": "^2.0.2", - "react": "^19.2.4", - "react-dom": "^19.2.4", - "react-feather": "^2.0.10" + "@vitejs/plugin-react": "^6.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vite-tsconfig-paths": "^5.0.0" + }, + "bin": { + "react-observatory": "bin/observe.js" }, "devDependencies": { "@eslint/js": "^9.39.4", @@ -20,18 +23,26 @@ "@types/node": "^24.12.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.1", "eslint": "^9.39.4", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.4.0", + "html2canvas-pro": "^2.0.2", "jsdom": "^29.0.1", "prettier": "^3.8.1", + "react-feather": "^2.0.10", "tsdown": "^0.21.7", "typescript": "~5.9.3", "typescript-eslint": "^8.57.0", - "vite": "^8.0.1", "vitest": "^4.1.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0", + "typescript": ">=5.0.0" } }, "node_modules/@adobe/css-tools": { @@ -509,7 +520,6 @@ "version": "1.9.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -521,7 +531,6 @@ "version": "1.9.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -532,7 +541,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -820,7 +828,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -837,7 +844,6 @@ "version": "0.122.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" @@ -863,7 +869,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -880,7 +885,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -897,7 +901,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -914,7 +917,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -931,7 +933,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -948,7 +949,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -965,7 +965,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -982,7 +981,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -999,7 +997,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1016,7 +1013,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1033,7 +1029,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1050,7 +1045,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1067,7 +1061,6 @@ "cpu": [ "wasm32" ], - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1084,7 +1077,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1101,7 +1093,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1115,7 +1106,6 @@ "version": "1.0.0-rc.7", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", - "dev": true, "license": "MIT" }, "node_modules/@standard-schema/spec": { @@ -1205,7 +1195,6 @@ "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1263,7 +1252,7 @@ "version": "24.12.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -1588,7 +1577,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", - "dev": true, "license": "MIT", "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" @@ -1906,6 +1894,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6.0" @@ -2110,6 +2099,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "dev": true, "license": "MIT", "dependencies": { "utrie": "^1.0.2" @@ -2161,7 +2151,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2210,7 +2199,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -2534,7 +2522,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -2603,7 +2590,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -2663,6 +2649,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globrex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", + "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", + "license": "MIT" + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2714,6 +2706,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/html2canvas-pro/-/html2canvas-pro-2.0.2.tgz", "integrity": "sha512-9G/t0XgCZWonLwL0JwI7su6NdbOPUY7Ur4Ihpp8+XMaW9ibA2nDXF181Jr6tm94k8lX6sthpaXB3XqEnsMd5Cw==", + "dev": true, "license": "MIT", "dependencies": { "css-line-break": "^2.1.0", @@ -2824,6 +2817,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -2965,7 +2959,6 @@ "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -2998,7 +2991,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -3019,7 +3011,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -3040,7 +3031,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -3061,7 +3051,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -3082,7 +3071,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -3103,7 +3091,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -3124,7 +3111,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -3145,7 +3131,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -3166,7 +3151,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -3187,7 +3171,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -3208,7 +3191,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -3249,6 +3231,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -3322,14 +3305,12 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, "funding": [ { "type": "github", @@ -3362,6 +3343,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -3485,14 +3467,12 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -3505,7 +3485,6 @@ "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", - "dev": true, "funding": [ { "type": "opencollective", @@ -3590,6 +3569,7 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -3601,6 +3581,7 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, "license": "MIT" }, "node_modules/punycode": { @@ -3635,6 +3616,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -3644,6 +3626,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -3655,6 +3638,7 @@ "version": "2.0.10", "resolved": "https://registry.npmjs.org/react-feather/-/react-feather-2.0.10.tgz", "integrity": "sha512-BLhukwJ+Z92Nmdcs+EMw6dy1Z/VLiJTzEQACDUEnWMClhYnFykJCGWQx+NmwP/qQHGX/5CzQ+TGi8ofg2+HzVQ==", + "dev": true, "license": "MIT", "dependencies": { "prop-types": "^15.7.2" @@ -3719,7 +3703,6 @@ "version": "1.0.0-rc.12", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", - "dev": true, "license": "MIT", "dependencies": { "@oxc-project/types": "=0.122.0", @@ -3867,7 +3850,6 @@ "version": "1.0.0-rc.12", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", - "dev": true, "license": "MIT" }, "node_modules/saxes": { @@ -3887,7 +3869,8 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/semver": { "version": "6.3.1", @@ -3933,7 +3916,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -4003,6 +3985,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "dev": true, "license": "MIT", "dependencies": { "utrie": "^1.0.2" @@ -4029,7 +4012,6 @@ "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -4121,6 +4103,26 @@ "typescript": ">=4.8.4" } }, + "node_modules/tsconfck": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz", + "integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==", + "license": "MIT", + "bin": { + "tsconfck": "bin/tsconfck.js" + }, + "engines": { + "node": "^18 || >=20" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/tsdown": { "version": "0.21.7", "resolved": "https://registry.npmjs.org/tsdown/-/tsdown-0.21.7.tgz", @@ -4204,7 +4206,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD", "optional": true }, @@ -4225,7 +4226,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -4287,7 +4288,7 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/unrun": { @@ -4362,6 +4363,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "dev": true, "license": "MIT", "dependencies": { "base64-arraybuffer": "^1.0.2" @@ -4371,7 +4373,6 @@ "version": "8.0.3", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", - "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", @@ -4445,6 +4446,25 @@ } } }, + "node_modules/vite-tsconfig-paths": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-5.1.4.tgz", + "integrity": "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "globrex": "^0.1.2", + "tsconfck": "^3.0.3" + }, + "peerDependencies": { + "vite": "*" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, "node_modules/vitest": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", diff --git a/package.json b/package.json index a8ccb20..dae8848 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,47 @@ { "name": "react-observatory", - "private": true, - "version": "0.0.0", + "version": "0.1.0", "type": "module", + "description": "A Vite plugin for exploring, testing, and getting AI feedback on React components", + "repository": { + "type": "git", + "url": "git+https://github.com/BAJ-/react-observatory.git" + }, + "license": "MIT", + "bin": { + "react-observatory": "./bin/observe.js" + }, + "exports": { + ".": { + "import": "./dist/plugin.mjs", + "types": "./dist/plugin.d.mts" + } + }, + "files": [ + "dist/", + "bin/" + ], + "keywords": [ + "vite-plugin", + "react", + "component", + "observatory", + "devtools" + ], + "engines": { + "node": ">=22.18.0" + }, + "publishConfig": { + "access": "public", + "provenance": true + }, "scripts": { "dev": "vite", - "build": "tsc -b && vite build", + "build": "npm run build:plugin && npm run build:ui", "build:plugin": "tsdown", "build:ui": "vite build --config vite.config.ui.ts", - "build:package": "npm run build:plugin && npm run build:ui", + "prepare": "npm run build", + "prepack": "npm run build", "typecheck": "tsc -b --noEmit", "lint": "eslint .", "test": "vitest run", @@ -18,10 +51,14 @@ "observe": "node bin/observe.js" }, "dependencies": { - "html2canvas-pro": "^2.0.2", - "react": "^19.2.4", - "react-dom": "^19.2.4", - "react-feather": "^2.0.10" + "@vitejs/plugin-react": "^6.0.0", + "vite": "^8.0.0", + "vite-tsconfig-paths": "^5.0.0" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "typescript": ">=5.8.0" }, "devDependencies": { "@eslint/js": "^9.39.4", @@ -30,17 +67,17 @@ "@types/node": "^24.12.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.1", "eslint": "^9.39.4", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.4.0", + "html2canvas-pro": "^2.0.2", "jsdom": "^29.0.1", "prettier": "^3.8.1", + "react-feather": "^2.0.10", "tsdown": "^0.21.7", "typescript": "~5.9.3", "typescript-eslint": "^8.57.0", - "vite": "^8.0.1", "vitest": "^4.1.2" } } From 11edf2eefd6192619388d6cb822b6240de6f2ed9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20A=2E=20Johansen?= Date: Sun, 5 Apr 2026 16:28:16 +0100 Subject: [PATCH 07/17] Rewrite CLI to use Vite createServer API instead of spawning child process --- bin/observe.js | 79 ++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 57 insertions(+), 22 deletions(-) diff --git a/bin/observe.js b/bin/observe.js index bcc4dfd..e5f5115 100755 --- a/bin/observe.js +++ b/bin/observe.js @@ -1,42 +1,77 @@ #!/usr/bin/env node -import { spawn } from 'node:child_process' import { resolve, relative } from 'node:path' import { fileURLToPath } from 'node:url' +import { existsSync } from 'node:fs' +import { createServer } from 'vite' +import react from '@vitejs/plugin-react' +import tsconfigPaths from 'vite-tsconfig-paths' const componentPath = process.argv[2] if (!componentPath) { - console.error('Usage: observe path/to/MyComponent.tsx') + console.error('Usage: react-observatory path/to/MyComponent.tsx') process.exit(1) } -const projectRoot = resolve(fileURLToPath(import.meta.url), '../..') -const abs = resolve(componentPath) +const cwd = process.cwd() +const abs = resolve(cwd, componentPath) -// Make it relative to project root so we don't leak absolute paths -const rel = relative(projectRoot, abs) +if (!existsSync(abs)) { + console.error(`Error: File not found: ${abs}`) + process.exit(1) +} + +const rel = relative(cwd, abs) if (rel.startsWith('..')) { - console.error('Error: Component must be inside the project directory.') + console.error( + 'Error: Component must be inside the current working directory.', + ) process.exit(1) } -const viteBin = resolve(projectRoot, 'node_modules/.bin/vite') - -// spawn avoids shell injection — no shell is involved -const existingNodeOptions = process.env.NODE_OPTIONS ?? '' -const child = spawn( - viteBin, - ['--open', `/?component=${encodeURIComponent(rel)}`], - { - cwd: projectRoot, - stdio: 'inherit', - env: { - ...process.env, - NODE_OPTIONS: `${existingNodeOptions} --expose-gc`.trim(), +const pkgRoot = resolve(fileURLToPath(import.meta.url), '../..') + +// Dynamic import so this works whether the user installed the package +// or is running from the repo itself. +const { observatory } = await import(resolve(pkgRoot, 'dist/plugin.mjs')) + +/** Check whether the user's Vite config already includes a React plugin. */ +function hasReactPlugin(plugins) { + const reactPluginNames = new Set([ + 'vite:react-babel', + 'vite:react-swc', + 'vite:react-refresh', + ]) + return plugins.some((p) => { + if (Array.isArray(p)) return p.some((pp) => reactPluginNames.has(pp.name)) + return reactPluginNames.has(p.name) + }) +} + +// Build the plugin list — always include observatory + tsconfigPaths, +// only add react() if the user's config doesn't already provide one. +const extraPlugins = [...observatory(), tsconfigPaths()] + +const server = await createServer({ + root: cwd, + plugins: [ + { + name: 'observatory:inject', + config(config) { + const existing = config.plugins?.flat() ?? [] + if (!hasReactPlugin(existing)) { + config.plugins = [react(), ...existing] + } + }, }, + ...extraPlugins, + ], + server: { + open: `/__observatory?component=${encodeURIComponent(rel)}`, }, -) +}) -child.on('exit', (code) => process.exit(code ?? 0)) +await server.listen() +server.printUrls() From 8631caccc01207c5fc64cd269382d5703b776602 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20A=2E=20Johansen?= Date: Sun, 5 Apr 2026 16:36:10 +0100 Subject: [PATCH 08/17] Wire plugin options and replace hardcoded paths with Vite config.root --- src/plugin/aiPlugin.ts | 30 ++++++++++++++++++------------ src/plugin/findTsconfig.ts | 16 ++++++++++++++++ src/plugin/index.ts | 26 +++++++++++++++++++++++--- src/plugin/schemaPlugin.ts | 11 +++++++---- src/plugin/stressPlugin.ts | 15 +++++++++------ 5 files changed, 73 insertions(+), 25 deletions(-) create mode 100644 src/plugin/findTsconfig.ts diff --git a/src/plugin/aiPlugin.ts b/src/plugin/aiPlugin.ts index cf2cabc..31eec0a 100644 --- a/src/plugin/aiPlugin.ts +++ b/src/plugin/aiPlugin.ts @@ -3,8 +3,8 @@ import { resolve, relative } from 'node:path' import type { Plugin } from 'vite' import type { IncomingMessage, ServerResponse } from 'node:http' import { API_AI_MODELS, API_AI_CHAT } from '../shared/constants' +import type { RootRef } from './index' -const OLLAMA_BASE = 'http://localhost:11434' const MAX_BODY_BYTES = 1_048_576 // 1 MB function readBody(req: IncomingMessage): Promise { @@ -38,9 +38,10 @@ function jsonResponse( async function handleModels( _req: IncomingMessage, res: ServerResponse, + ollamaUrl: string, ): Promise { try { - const response = await fetch(`${OLLAMA_BASE}/api/tags`) + const response = await fetch(`${ollamaUrl}/api/tags`) if (!response.ok) { jsonResponse(res, 502, { error: 'Failed to reach Ollama' }) return @@ -55,7 +56,7 @@ async function handleModels( jsonResponse(res, 200, { models }) } catch { jsonResponse(res, 502, { - error: 'Ollama is not running at ' + OLLAMA_BASE, + error: 'Ollama is not running at ' + ollamaUrl, }) } } @@ -66,9 +67,12 @@ interface ChatRequest { component?: string } -function readComponentSource(componentPath: string): string | null { - const absPath = resolve(process.cwd(), componentPath) - const rel = relative(process.cwd(), absPath) +function readComponentSource( + componentPath: string, + root: string, +): string | null { + const absPath = resolve(root, componentPath) + const rel = relative(root, absPath) if (rel.startsWith('..')) return null try { return readFileSync(absPath, 'utf-8') @@ -80,6 +84,8 @@ function readComponentSource(componentPath: string): string | null { async function handleChat( req: IncomingMessage, res: ServerResponse, + ollamaUrl: string, + rootRef: RootRef, ): Promise { if (req.method !== 'POST') { jsonResponse(res, 405, { error: 'Method not allowed' }) @@ -109,7 +115,7 @@ async function handleChat( // If component path is provided, read source and inject as system context const messages = [...params.messages] if (params.component) { - const source = readComponentSource(params.component) + const source = readComponentSource(params.component, rootRef.root) if (source) { const systemMsg = messages.find((m) => m.role === 'system') const sourceBlock = `\n\nComponent source code:\n\`\`\`tsx\n${source}\n\`\`\`` @@ -122,7 +128,7 @@ async function handleChat( } try { - const response = await fetch(`${OLLAMA_BASE}/api/chat`, { + const response = await fetch(`${ollamaUrl}/api/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -171,25 +177,25 @@ async function handleChat( } catch { if (!res.headersSent) { jsonResponse(res, 502, { - error: 'Ollama is not running at ' + OLLAMA_BASE, + error: 'Ollama is not running at ' + ollamaUrl, }) } } } -export function aiPlugin(): Plugin { +export function aiPlugin(ollamaUrl: string, rootRef: RootRef): Plugin { return { name: 'observatory-ai', configureServer(server) { server.middlewares.use(API_AI_MODELS, (req, res) => { - handleModels(req, res).catch((err) => { + handleModels(req, res, ollamaUrl).catch((err) => { if (!res.headersSent) { jsonResponse(res, 500, { error: String(err) }) } }) }) server.middlewares.use(API_AI_CHAT, (req, res) => { - handleChat(req, res).catch((err) => { + handleChat(req, res, ollamaUrl, rootRef).catch((err) => { if (!res.headersSent) { jsonResponse(res, 500, { error: String(err) }) } diff --git a/src/plugin/findTsconfig.ts b/src/plugin/findTsconfig.ts new file mode 100644 index 0000000..7a7711e --- /dev/null +++ b/src/plugin/findTsconfig.ts @@ -0,0 +1,16 @@ +import { resolve } from 'node:path' +import { existsSync } from 'node:fs' + +/** + * Find the best tsconfig to use for TypeScript analysis. + * Prefers `tsconfig.app.json` (Vite convention), falls back to `tsconfig.json`. + */ +export function findTsconfig(root: string): string { + const app = resolve(root, 'tsconfig.app.json') + if (existsSync(app)) return app + + const base = resolve(root, 'tsconfig.json') + if (existsSync(base)) return base + + return app // fall back to app path so the error message is clear +} diff --git a/src/plugin/index.ts b/src/plugin/index.ts index e68419a..718eb22 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -1,4 +1,4 @@ -import type { Plugin } from 'vite' +import type { Plugin, ResolvedConfig } from 'vite' import { schemaPlugin } from './schemaPlugin' import { stressPlugin } from './stressPlugin' import { aiPlugin } from './aiPlugin' @@ -9,6 +9,11 @@ export interface ObservatoryOptions { ollamaUrl?: string } +/** Mutable ref so configResolved can set root after plugin creation. */ +export interface RootRef { + root: string +} + /** * Create the React Observatory Vite plugin array. * @@ -21,8 +26,23 @@ export interface ObservatoryOptions { * ``` */ export function observatory(options?: ObservatoryOptions): Plugin[] { - void options // wired in Step 7 - return [uiPlugin(), schemaPlugin(), stressPlugin(), aiPlugin()] + const rootRef: RootRef = { root: process.cwd() } + const ollamaUrl = options?.ollamaUrl ?? 'http://localhost:11434' + + const rootPlugin: Plugin = { + name: 'observatory:root', + configResolved(config: ResolvedConfig) { + rootRef.root = config.root + }, + } + + return [ + rootPlugin, + uiPlugin(), + schemaPlugin(rootRef), + stressPlugin(rootRef), + aiPlugin(ollamaUrl, rootRef), + ] } export type { PropInfo } from '../shared/types' diff --git a/src/plugin/schemaPlugin.ts b/src/plugin/schemaPlugin.ts index b09cc37..da0ff30 100644 --- a/src/plugin/schemaPlugin.ts +++ b/src/plugin/schemaPlugin.ts @@ -3,6 +3,8 @@ import { resolve } from 'node:path' import type { Plugin } from 'vite' import { API_SCHEMA, HMR_SCHEMA_UPDATE } from '../shared/constants' import type { PropInfo } from '../shared/types' +import { findTsconfig } from './findTsconfig' +import type { RootRef } from './index' export type { PropInfo } @@ -223,7 +225,7 @@ function symbolToPropInfo( return { name: symbol.name, type: 'unknown', required } } -export function schemaPlugin(): Plugin { +export function schemaPlugin(rootRef: RootRef): Plugin { return { name: 'observatory-schema', configureServer(server) { @@ -237,17 +239,18 @@ export function schemaPlugin(): Plugin { return } - const absPath = resolve(process.cwd(), componentPath) + const root = rootRef.root + const absPath = resolve(root, componentPath) // Verify the file is inside the project root - if (!absPath.startsWith(process.cwd())) { + if (!absPath.startsWith(root)) { res.writeHead(403, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: 'Path outside project root' })) return } try { - const tsconfigPath = resolve(process.cwd(), 'tsconfig.app.json') + const tsconfigPath = findTsconfig(root) const props = extractProps(absPath, tsconfigPath) res.writeHead(200, { 'Content-Type': 'application/json' }) diff --git a/src/plugin/stressPlugin.ts b/src/plugin/stressPlugin.ts index 6734b6a..50d9b48 100644 --- a/src/plugin/stressPlugin.ts +++ b/src/plugin/stressPlugin.ts @@ -6,6 +6,8 @@ import { computeStats } from '../shared/stressStats' import type { StressResult } from '../shared/analyzeHealth' import { extractProps } from './schemaPlugin' import { hydrateProps } from './hydrateProps' +import { findTsconfig } from './findTsconfig' +import type { RootRef } from './index' interface StressRequest { component: string @@ -51,6 +53,7 @@ async function handleStress( req: IncomingMessage, res: ServerResponse, server: ViteDevServer, + rootRef: RootRef, ): Promise { if (req.method !== 'POST') { res.writeHead(405, { 'Content-Type': 'application/json' }) @@ -101,8 +104,8 @@ async function handleStress( return } - const absPath = resolve(process.cwd(), component) - const rel = relative(process.cwd(), absPath) + const absPath = resolve(rootRef.root, component) + const rel = relative(rootRef.root, absPath) if (rel.startsWith('..')) { res.writeHead(403, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: 'Path outside project root' })) @@ -123,11 +126,11 @@ async function handleStress( // Load the rendering helper via SSR so React is resolved through // Vite's normal externalization (avoids CJS/ESM mismatch). const { render } = (await server.ssrLoadModule( - '/src/observatory/stressRender.ts', + '/src/plugin/stressRender.ts', )) as { render: (comp: unknown, props: Record) => string } // Hydrate function props so the component receives callable stubs - const tsconfigPath = resolve(process.cwd(), 'tsconfig.app.json') + const tsconfigPath = findTsconfig(rootRef.root) const propInfos = extractProps(absPath, tsconfigPath) const hydratedProps = hydrateProps(props, propInfos) @@ -212,12 +215,12 @@ async function handleStress( } } -export function stressPlugin(): Plugin { +export function stressPlugin(rootRef: RootRef): Plugin { return { name: 'observatory-stress', configureServer(server) { server.middlewares.use(API_STRESS, (req, res) => { - handleStress(req, res, server).catch((err) => { + handleStress(req, res, server, rootRef).catch((err) => { if (!res.headersSent) { res.writeHead(500, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: String(err) })) From 514bd80e0cd980dbd35e944d4f9fcf2114ad1ef0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20A=2E=20Johansen?= Date: Sun, 5 Apr 2026 16:44:50 +0100 Subject: [PATCH 09/17] Remove test components --- src/sandbox/LeakyButton.tsx | 41 -------------------------------- src/sandbox/TestButton.tsx | 33 ------------------------- src/ui/usePinnedVariants.test.ts | 2 +- 3 files changed, 1 insertion(+), 75 deletions(-) delete mode 100644 src/sandbox/LeakyButton.tsx delete mode 100644 src/sandbox/TestButton.tsx diff --git a/src/sandbox/LeakyButton.tsx b/src/sandbox/LeakyButton.tsx deleted file mode 100644 index 831a432..0000000 --- a/src/sandbox/LeakyButton.tsx +++ /dev/null @@ -1,41 +0,0 @@ -/** - * A deliberately leaky component for testing. - * The module-level array grows on every render, simulating - * a component that accumulates state outside React's lifecycle. - */ - -interface LeakyButtonProps { - label: string - onClick: () => void - disabled?: boolean -} - -// This is the leak: grows on every render, never cleaned up -const renderLog: string[] = [] - -const LeakyButton = ({ - label, - onClick, - disabled = false, -}: LeakyButtonProps) => { - // Every render adds to the array AND iterates the whole thing - renderLog.push(`rendered: ${label}`) - - return ( - - ) -} - -export default LeakyButton diff --git a/src/sandbox/TestButton.tsx b/src/sandbox/TestButton.tsx deleted file mode 100644 index 71cba0a..0000000 --- a/src/sandbox/TestButton.tsx +++ /dev/null @@ -1,33 +0,0 @@ -interface TestButtonProps { - label: string - onClick: () => void - disabled?: boolean - variant?: 'primary' | 'secondary' -} - -const TestButton = ({ - label, - onClick, - disabled = false, - variant = 'primary', -}: TestButtonProps) => { - return ( - - ) -} - -export default TestButton diff --git a/src/ui/usePinnedVariants.test.ts b/src/ui/usePinnedVariants.test.ts index 97aebe8..b34ff7f 100644 --- a/src/ui/usePinnedVariants.test.ts +++ b/src/ui/usePinnedVariants.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach } from 'vitest' import { renderHook, act } from '@testing-library/react' import { usePinnedVariants } from './usePinnedVariants' -const COMPONENT = 'src/sandbox/TestButton.tsx' +const COMPONENT = 'src/components/TestButton.tsx' const storageKey = `observatory:pinned:${COMPONENT}` beforeEach(() => { From 59cb32609ab85143eefd0765436f21a93f0ac4f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20A=2E=20Johansen?= Date: Sun, 5 Apr 2026 17:17:08 +0100 Subject: [PATCH 10/17] Fix npm package serving: render iframe, asset resolution, and stress test paths --- bin/observe.js | 8 +++-- package.json | 9 ++--- src/plugin/stressPlugin.ts | 17 +++++++--- src/plugin/uiPlugin.ts | 66 ++++++++++++++++++++++++++++++++---- src/renderEntry.tsx | 6 ++++ src/ui/ComponentRenderer.tsx | 2 +- tsconfig.node.json | 7 +++- vite.config.render.ts | 24 +++++++++++++ 8 files changed, 119 insertions(+), 20 deletions(-) create mode 100644 src/renderEntry.tsx create mode 100644 vite.config.render.ts diff --git a/bin/observe.js b/bin/observe.js index e5f5115..135153c 100755 --- a/bin/observe.js +++ b/bin/observe.js @@ -5,7 +5,6 @@ import { fileURLToPath } from 'node:url' import { existsSync } from 'node:fs' import { createServer } from 'vite' import react from '@vitejs/plugin-react' -import tsconfigPaths from 'vite-tsconfig-paths' const componentPath = process.argv[2] @@ -50,9 +49,9 @@ function hasReactPlugin(plugins) { }) } -// Build the plugin list — always include observatory + tsconfigPaths, +// Build the plugin list — always include observatory, // only add react() if the user's config doesn't already provide one. -const extraPlugins = [...observatory(), tsconfigPaths()] +const extraPlugins = [...observatory()] const server = await createServer({ root: cwd, @@ -68,6 +67,9 @@ const server = await createServer({ }, ...extraPlugins, ], + resolve: { + tsconfigPaths: true, + }, server: { open: `/__observatory?component=${encodeURIComponent(rel)}`, }, diff --git a/package.json b/package.json index dae8848..1470a81 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ }, "files": [ "dist/", - "bin/" + "bin/", + "src/plugin/stressRender.ts" ], "keywords": [ "vite-plugin", @@ -37,9 +38,10 @@ }, "scripts": { "dev": "vite", - "build": "npm run build:plugin && npm run build:ui", + "build": "npm run build:plugin && npm run build:ui && npm run build:render", "build:plugin": "tsdown", "build:ui": "vite build --config vite.config.ui.ts", + "build:render": "vite build --config vite.config.render.ts", "prepare": "npm run build", "prepack": "npm run build", "typecheck": "tsc -b --noEmit", @@ -52,8 +54,7 @@ }, "dependencies": { "@vitejs/plugin-react": "^6.0.0", - "vite": "^8.0.0", - "vite-tsconfig-paths": "^5.0.0" + "vite": "^8.0.0" }, "peerDependencies": { "react": "^19.0.0", diff --git a/src/plugin/stressPlugin.ts b/src/plugin/stressPlugin.ts index 50d9b48..a9c7aa1 100644 --- a/src/plugin/stressPlugin.ts +++ b/src/plugin/stressPlugin.ts @@ -1,4 +1,6 @@ -import { resolve, relative } from 'node:path' +import { resolve, relative, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { existsSync } from 'node:fs' import type { Plugin, ViteDevServer } from 'vite' import type { IncomingMessage, ServerResponse } from 'node:http' import { API_STRESS } from '../shared/constants' @@ -125,9 +127,16 @@ async function handleStress( // Load the rendering helper via SSR so React is resolved through // Vite's normal externalization (avoids CJS/ESM mismatch). - const { render } = (await server.ssrLoadModule( - '/src/plugin/stressRender.ts', - )) as { render: (comp: unknown, props: Record) => string } + const selfDir = dirname(fileURLToPath(import.meta.url)) + // In dev: selfDir is src/plugin/, stressRender.ts is a sibling. + // As npm package: selfDir is dist/, stressRender.ts is at ../src/plugin/. + const localPath = resolve(selfDir, 'stressRender.ts') + const stressRenderPath = existsSync(localPath) + ? localPath + : resolve(selfDir, '..', 'src', 'plugin', 'stressRender.ts') + const { render } = (await server.ssrLoadModule(stressRenderPath)) as { + render: (comp: unknown, props: Record) => string + } // Hydrate function props so the component receives callable stubs const tsconfigPath = findTsconfig(rootRef.root) diff --git a/src/plugin/uiPlugin.ts b/src/plugin/uiPlugin.ts index 95f2ecc..6a6331a 100644 --- a/src/plugin/uiPlugin.ts +++ b/src/plugin/uiPlugin.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync } from 'node:fs' +import { existsSync, readFileSync, statSync } from 'node:fs' import { resolve, dirname, extname } from 'node:path' import { fileURLToPath } from 'node:url' import type { Plugin } from 'vite' @@ -15,6 +15,11 @@ const CONTENT_TYPES: Record = { '.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/. * @@ -22,20 +27,62 @@ const CONTENT_TYPES: Record = { * react-observatory 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 + export function uiPlugin(): Plugin { const selfDir = dirname(fileURLToPath(import.meta.url)) - // When built: dist/plugin.mjs → resolve dist/client/ - // When running from source: src/plugin/ → resolve ../../dist/client/ + // When built: dist/plugin.mjs → dist/client/ and dist/render/ const clientDir = resolve(selfDir, 'client') + const renderEntry = resolve(selfDir, 'render', 'entry.js') return { name: 'observatory-ui', + resolveId(id) { + if (id === VIRTUAL_RENDER_ID) return RESOLVED_RENDER_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') + } + }, configureServer(server) { if (!existsSync(clientDir)) { // No pre-built UI — we're in RO 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 ?? '' + const qs = url.indexOf('?') + if (qs < 0) { + next() + return + } + const params = new URLSearchParams(url.slice(qs)) + if (!params.has('render')) { + next() + return + } + const html = ` + +
+ +` + server + .transformIndexHtml(url, html) + .then((transformed) => { + res.writeHead(200, { 'Content-Type': 'text/html' }) + res.end(transformed) + }) + .catch(next) + return + }) + server.middlewares.use((req, res, next) => { const url = req.url ?? '' @@ -53,9 +100,14 @@ export function uiPlugin(): Plugin { assetPath = assetPath.slice(0, queryIndex) } - // Serve index.html for the base route + // Serve index.html for the base route (with tag injected) if (assetPath === '/' || assetPath === '/index.html') { - 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)) @@ -67,12 +119,12 @@ export function uiPlugin(): Plugin { return } - if (!existsSync(filePath)) { + 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(readFileSync(indexPath, 'utf-8')) + res.end(injectBase(readFileSync(indexPath, 'utf-8'))) return } res.writeHead(404) diff --git a/src/renderEntry.tsx b/src/renderEntry.tsx new file mode 100644 index 0000000..b40c106 --- /dev/null +++ b/src/renderEntry.tsx @@ -0,0 +1,6 @@ +import { StrictMode, createElement } from 'react' +import { createRoot } from 'react-dom/client' +import { ComponentRenderer } from './ui/ComponentRenderer' + +const root = createRoot(document.getElementById('root')!) +root.render(createElement(StrictMode, null, createElement(ComponentRenderer))) diff --git a/src/ui/ComponentRenderer.tsx b/src/ui/ComponentRenderer.tsx index fd4f709..70e932c 100644 --- a/src/ui/ComponentRenderer.tsx +++ b/src/ui/ComponentRenderer.tsx @@ -26,7 +26,7 @@ export function ComponentRenderer() { useEffect(() => { if (!componentPath) return - import(/* @vite-ignore */ `../${componentPath.replace(/^src\//, '')}`) + import(/* @vite-ignore */ `/${componentPath}`) .then((module) => { const Comp = module.default ?? diff --git a/tsconfig.node.json b/tsconfig.node.json index c25c54a..0f2f573 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -22,5 +22,10 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["vite.config.ts", "vite.config.ui.ts", "tsdown.config.ts"] + "include": [ + "vite.config.ts", + "vite.config.ui.ts", + "vite.config.render.ts", + "tsdown.config.ts" + ] } diff --git a/vite.config.render.ts b/vite.config.render.ts new file mode 100644 index 0000000..bb61679 --- /dev/null +++ b/vite.config.render.ts @@ -0,0 +1,24 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + build: { + outDir: 'dist/render', + emptyOutDir: true, + lib: { + entry: 'src/renderEntry.tsx', + formats: ['es'], + fileName: 'entry', + }, + rollupOptions: { + external: [ + 'react', + 'react-dom', + 'react/jsx-runtime', + 'react/jsx-dev-runtime', + 'react-dom/client', + ], + }, + }, +}) From 744259c2adc9eab2fa9fe8c251c02f2191d07917 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20A=2E=20Johansen?= Date: Sun, 5 Apr 2026 17:57:00 +0100 Subject: [PATCH 11/17] Rename package to reactoscope and add LICENSE --- LICENSE | 21 +++++++++++ README.md | 81 +++++++++++++++++++++++++++++++++++++++++- bin/observe.js | 2 +- index.html | 2 +- package-lock.json | 66 ++++++---------------------------- package.json | 13 +++---- src/plugin/index.ts | 2 +- src/plugin/uiPlugin.ts | 2 +- 8 files changed, 123 insertions(+), 66 deletions(-) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..eef5c6c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Bjørn A. Johansen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 3d59550..96f9569 100644 --- a/README.md +++ b/README.md @@ -1 +1,80 @@ -# React Observatory +# Reactoscope + +Explore, stress-test, and get AI feedback on your React components — without writing a single test or storybook file. + +## Features + +- **Explore** — Renders any component with auto-generated prop controls based on its TypeScript types +- **Stress Test** — Measures render performance, detects non-determinism, and spots memory leaks via server-side rendering +- **AI Feedback** — Connects to a local Ollama instance to review component source code and provide suggestions + +## Quick Start + +### As a standalone CLI (no Vite project required) + +```bash +npx reactoscope path/to/MyComponent.tsx +``` + +This starts a dev server and opens the Observatory UI in your browser. + +### As a Vite plugin + +```bash +npm install reactoscope --save-dev +``` + +Add it to your Vite config: + +```ts +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import { observatory } from 'reactoscope' + +export default defineConfig({ + plugins: [react(), ...observatory()], +}) +``` + +Then visit `/__observatory?component=path/to/MyComponent.tsx` in your browser while the dev server is running. + +## Options + +```ts +observatory({ + ollamaUrl: 'http://localhost:11434', // default +}) +``` + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `ollamaUrl` | `string` | `http://localhost:11434` | Base URL for the Ollama API used by the AI feedback panel | + +## Requirements + +- Node.js >= 22.18.0 +- React 19 +- TypeScript >= 5.8 + +## How It Works + +Reactoscope is a set of Vite plugins that: + +1. **Schema plugin** — Parses your component's TypeScript props at dev time and serves them as JSON, powering the auto-generated prop controls +2. **Stress plugin** — Renders your component server-side in a loop, measuring timing, output determinism, and heap growth +3. **AI plugin** — Proxies requests to a local Ollama instance, injecting your component's source code as context +4. **UI plugin** — Serves the pre-built Reactoscope dashboard at `/__observatory` + +The CLI wraps all of this into a single command using Vite's `createServer` API, so it works even in projects that don't use Vite. + +## AI Feedback + +The AI panel requires [Ollama](https://ollama.ai) running locally. Install it, pull a model, and the panel will auto-detect available models: + +```bash +ollama pull llama3 +``` + +## License + +MIT diff --git a/bin/observe.js b/bin/observe.js index 135153c..4b264de 100755 --- a/bin/observe.js +++ b/bin/observe.js @@ -9,7 +9,7 @@ import react from '@vitejs/plugin-react' const componentPath = process.argv[2] if (!componentPath) { - console.error('Usage: react-observatory path/to/MyComponent.tsx') + console.error('Usage: reactoscope path/to/MyComponent.tsx') process.exit(1) } diff --git a/index.html b/index.html index 897dc5d..a8baa1a 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - react-observatory + Reactoscope
diff --git a/package-lock.json b/package-lock.json index 9d6e083..2fecde1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,20 +1,19 @@ { - "name": "react-observatory", + "name": "reactoscope", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "react-observatory", + "name": "reactoscope", "version": "0.1.0", "license": "MIT", "dependencies": { "@vitejs/plugin-react": "^6.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", - "vite-tsconfig-paths": "^5.0.0" + "vite": "^8.0.0" }, "bin": { - "react-observatory": "bin/observe.js" + "reactoscope": "bin/observe.js" }, "devDependencies": { "@eslint/js": "^9.39.4", @@ -37,12 +36,12 @@ "vitest": "^4.1.2" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.18.0" }, "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0", - "typescript": ">=5.0.0" + "react": "^19.0.0", + "react-dom": "^19.0.0", + "typescript": ">=5.8.0" } }, "node_modules/@adobe/css-tools": { @@ -2151,6 +2150,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2649,12 +2649,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/globrex": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", - "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", - "license": "MIT" - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -3305,6 +3299,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -4103,26 +4098,6 @@ "typescript": ">=4.8.4" } }, - "node_modules/tsconfck": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz", - "integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==", - "license": "MIT", - "bin": { - "tsconfck": "bin/tsconfck.js" - }, - "engines": { - "node": "^18 || >=20" - }, - "peerDependencies": { - "typescript": "^5.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, "node_modules/tsdown": { "version": "0.21.7", "resolved": "https://registry.npmjs.org/tsdown/-/tsdown-0.21.7.tgz", @@ -4226,7 +4201,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -4446,25 +4421,6 @@ } } }, - "node_modules/vite-tsconfig-paths": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-5.1.4.tgz", - "integrity": "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==", - "license": "MIT", - "dependencies": { - "debug": "^4.1.1", - "globrex": "^0.1.2", - "tsconfck": "^3.0.3" - }, - "peerDependencies": { - "vite": "*" - }, - "peerDependenciesMeta": { - "vite": { - "optional": true - } - } - }, "node_modules/vitest": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", diff --git a/package.json b/package.json index 1470a81..ba56d0a 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,15 @@ { - "name": "react-observatory", + "name": "reactoscope", "version": "0.1.0", "type": "module", - "description": "A Vite plugin for exploring, testing, and getting AI feedback on React components", + "description": "Explore, stress-test, and get AI feedback on your React components", "repository": { "type": "git", "url": "git+https://github.com/BAJ-/react-observatory.git" }, "license": "MIT", "bin": { - "react-observatory": "./bin/observe.js" + "reactoscope": "./bin/observe.js" }, "exports": { ".": { @@ -23,11 +23,12 @@ "src/plugin/stressRender.ts" ], "keywords": [ - "vite-plugin", "react", "component", - "observatory", - "devtools" + "devtools", + "vite-plugin", + "stress-test", + "reactoscope" ], "engines": { "node": ">=22.18.0" diff --git a/src/plugin/index.ts b/src/plugin/index.ts index 718eb22..171dfd8 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -19,7 +19,7 @@ export interface RootRef { * * Usage: * ```ts - * import { observatory } from 'react-observatory' + * import { observatory } from 'reactoscope' * export default defineConfig({ * plugins: [react(), ...observatory()] * }) diff --git a/src/plugin/uiPlugin.ts b/src/plugin/uiPlugin.ts index 6a6331a..fdcda40 100644 --- a/src/plugin/uiPlugin.ts +++ b/src/plugin/uiPlugin.ts @@ -24,7 +24,7 @@ function injectBase(html: string): string { * Vite plugin that serves the pre-built Observatory UI from dist/client/. * * Only activates when the pre-built client directory exists (i.e., when - * react-observatory is installed as an npm package). During RO development, + * 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' From 12a987e455dfb7e1d1e163aad392bfc4917301f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20A=2E=20Johansen?= Date: Sun, 5 Apr 2026 18:32:36 +0100 Subject: [PATCH 12/17] Fix path traversal checks and scope render route to /__observatory --- src/plugin/schemaPlugin.ts | 5 +++-- src/plugin/uiPlugin.ts | 9 +++++++-- src/ui/buildIframeSrc.ts | 2 +- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/plugin/schemaPlugin.ts b/src/plugin/schemaPlugin.ts index da0ff30..f83dbce 100644 --- a/src/plugin/schemaPlugin.ts +++ b/src/plugin/schemaPlugin.ts @@ -1,5 +1,5 @@ import ts from 'typescript' -import { resolve } from 'node:path' +import { resolve, relative } from 'node:path' import type { Plugin } from 'vite' import { API_SCHEMA, HMR_SCHEMA_UPDATE } from '../shared/constants' import type { PropInfo } from '../shared/types' @@ -243,7 +243,8 @@ export function schemaPlugin(rootRef: RootRef): Plugin { const absPath = resolve(root, componentPath) // Verify the file is inside the project root - if (!absPath.startsWith(root)) { + const rel = relative(root, absPath) + if (rel.startsWith('..') || rel.startsWith('/')) { res.writeHead(403, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: 'Path outside project root' })) return diff --git a/src/plugin/uiPlugin.ts b/src/plugin/uiPlugin.ts index fdcda40..ecc024b 100644 --- a/src/plugin/uiPlugin.ts +++ b/src/plugin/uiPlugin.ts @@ -1,5 +1,5 @@ import { existsSync, readFileSync, statSync } from 'node:fs' -import { resolve, dirname, extname } from 'node:path' +import { resolve, relative, dirname, extname } from 'node:path' import { fileURLToPath } from 'node:url' import type { Plugin } from 'vite' @@ -58,6 +58,10 @@ export function uiPlugin(): Plugin { // /?render=&component=... loads ComponentRenderer via Vite's pipeline. server.middlewares.use((req, res, next) => { const url = req.url ?? '' + if (!url.startsWith(ROUTE_BASE)) { + next() + return + } const qs = url.indexOf('?') if (qs < 0) { next() @@ -113,7 +117,8 @@ export function uiPlugin(): Plugin { const filePath = resolve(clientDir, assetPath.slice(1)) // Security: ensure resolved path is within clientDir - if (!filePath.startsWith(clientDir)) { + const relPath = relative(clientDir, filePath) + if (relPath.startsWith('..') || relPath.startsWith('/')) { res.writeHead(403) res.end() return diff --git a/src/ui/buildIframeSrc.ts b/src/ui/buildIframeSrc.ts index f7de758..b72cde7 100644 --- a/src/ui/buildIframeSrc.ts +++ b/src/ui/buildIframeSrc.ts @@ -8,5 +8,5 @@ export function buildIframeSrc( params.set('render', '') params.set('component', componentPath) params.set('props', JSON.stringify(props)) - return `/?${params.toString()}` + return `/__observatory?${params.toString()}` } From ee65bf93961ad151d6f814a3cc47b56d99d5464f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20A=2E=20Johansen?= Date: Sun, 5 Apr 2026 18:45:30 +0100 Subject: [PATCH 13/17] Compile stressRender into dist/ and remove duplicate prepare script --- package.json | 4 +--- src/plugin/stressPlugin.ts | 4 ++-- tsdown.config.ts | 5 ++++- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index ba56d0a..70003e6 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,7 @@ }, "files": [ "dist/", - "bin/", - "src/plugin/stressRender.ts" + "bin/" ], "keywords": [ "react", @@ -43,7 +42,6 @@ "build:plugin": "tsdown", "build:ui": "vite build --config vite.config.ui.ts", "build:render": "vite build --config vite.config.render.ts", - "prepare": "npm run build", "prepack": "npm run build", "typecheck": "tsc -b --noEmit", "lint": "eslint .", diff --git a/src/plugin/stressPlugin.ts b/src/plugin/stressPlugin.ts index a9c7aa1..cd838e2 100644 --- a/src/plugin/stressPlugin.ts +++ b/src/plugin/stressPlugin.ts @@ -129,11 +129,11 @@ async function handleStress( // Vite's normal externalization (avoids CJS/ESM mismatch). const selfDir = dirname(fileURLToPath(import.meta.url)) // In dev: selfDir is src/plugin/, stressRender.ts is a sibling. - // As npm package: selfDir is dist/, stressRender.ts is at ../src/plugin/. + // As npm package: selfDir is dist/, stressRender.mjs is a sibling. const localPath = resolve(selfDir, 'stressRender.ts') const stressRenderPath = existsSync(localPath) ? localPath - : resolve(selfDir, '..', 'src', 'plugin', 'stressRender.ts') + : resolve(selfDir, 'stressRender.mjs') const { render } = (await server.ssrLoadModule(stressRenderPath)) as { render: (comp: unknown, props: Record) => string } diff --git a/tsdown.config.ts b/tsdown.config.ts index 9bb19ac..788b346 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -1,7 +1,10 @@ import { defineConfig } from 'tsdown' export default defineConfig({ - entry: { plugin: './src/plugin/index.ts' }, + entry: { + plugin: './src/plugin/index.ts', + stressRender: './src/plugin/stressRender.ts', + }, outDir: './dist', format: 'esm', platform: 'node', From 7b60c73c3a9e8974a6d7247d8e6c4b6c3fb046e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20A=2E=20Johansen?= Date: Sun, 5 Apr 2026 19:26:51 +0100 Subject: [PATCH 14/17] Add publish workflow --- .github/workflows/publish.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..a815981 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,28 @@ +name: Publish + +on: + release: + types: [published] + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + registry-url: 'https://registry.npmjs.org' + - run: npm ci + - run: npm run typecheck + - run: npm run lint + - run: npm test + - run: npm audit --omit=dev + - run: npm pack --dry-run + - run: npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} From c74c546b9343cc45da9f1285bf3798c1fe6cb0f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20A=2E=20Johansen?= Date: Sun, 5 Apr 2026 19:32:46 +0100 Subject: [PATCH 15/17] prettier --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 96f9569..09e1e4d 100644 --- a/README.md +++ b/README.md @@ -46,8 +46,8 @@ observatory({ }) ``` -| Option | Type | Default | Description | -|--------|------|---------|-------------| +| Option | Type | Default | Description | +| ----------- | -------- | ------------------------ | --------------------------------------------------------- | | `ollamaUrl` | `string` | `http://localhost:11434` | Base URL for the Ollama API used by the AI feedback panel | ## Requirements From 82fbd845e2c510e4561225dfd7a100c8f30acc0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20A=2E=20Johansen?= Date: Mon, 6 Apr 2026 17:30:04 +0100 Subject: [PATCH 16/17] Add react and react-dom to tsdown externals --- tsdown.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tsdown.config.ts b/tsdown.config.ts index 788b346..283a959 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -10,5 +10,5 @@ export default defineConfig({ platform: 'node', target: 'node20', dts: { build: true }, - external: ['typescript', 'vite'], + external: ['typescript', 'vite', 'react', 'react-dom'], }) From 6ad85d3b4194ac18dff9298524188e0a20502781 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20A=2E=20Johansen?= Date: Mon, 6 Apr 2026 17:34:52 +0100 Subject: [PATCH 17/17] Use isAbsolute() in path containment checks for Windows cross-drive safety --- src/plugin/aiPlugin.ts | 4 ++-- src/plugin/schemaPlugin.ts | 4 ++-- src/plugin/stressPlugin.ts | 4 ++-- src/plugin/uiPlugin.ts | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/plugin/aiPlugin.ts b/src/plugin/aiPlugin.ts index 31eec0a..0fc6d61 100644 --- a/src/plugin/aiPlugin.ts +++ b/src/plugin/aiPlugin.ts @@ -1,5 +1,5 @@ import { readFileSync } from 'node:fs' -import { resolve, relative } from 'node:path' +import { resolve, relative, isAbsolute } from 'node:path' import type { Plugin } from 'vite' import type { IncomingMessage, ServerResponse } from 'node:http' import { API_AI_MODELS, API_AI_CHAT } from '../shared/constants' @@ -73,7 +73,7 @@ function readComponentSource( ): string | null { const absPath = resolve(root, componentPath) const rel = relative(root, absPath) - if (rel.startsWith('..')) return null + if (rel.startsWith('..') || isAbsolute(rel)) return null try { return readFileSync(absPath, 'utf-8') } catch { diff --git a/src/plugin/schemaPlugin.ts b/src/plugin/schemaPlugin.ts index f83dbce..3ce78df 100644 --- a/src/plugin/schemaPlugin.ts +++ b/src/plugin/schemaPlugin.ts @@ -1,5 +1,5 @@ import ts from 'typescript' -import { resolve, relative } from 'node:path' +import { resolve, relative, isAbsolute } from 'node:path' import type { Plugin } from 'vite' import { API_SCHEMA, HMR_SCHEMA_UPDATE } from '../shared/constants' import type { PropInfo } from '../shared/types' @@ -244,7 +244,7 @@ export function schemaPlugin(rootRef: RootRef): Plugin { // Verify the file is inside the project root const rel = relative(root, absPath) - if (rel.startsWith('..') || rel.startsWith('/')) { + if (rel.startsWith('..') || isAbsolute(rel)) { res.writeHead(403, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: 'Path outside project root' })) return diff --git a/src/plugin/stressPlugin.ts b/src/plugin/stressPlugin.ts index cd838e2..38fbd8f 100644 --- a/src/plugin/stressPlugin.ts +++ b/src/plugin/stressPlugin.ts @@ -1,4 +1,4 @@ -import { resolve, relative, dirname } from 'node:path' +import { resolve, relative, isAbsolute, dirname } from 'node:path' import { fileURLToPath } from 'node:url' import { existsSync } from 'node:fs' import type { Plugin, ViteDevServer } from 'vite' @@ -108,7 +108,7 @@ async function handleStress( const absPath = resolve(rootRef.root, component) const rel = relative(rootRef.root, absPath) - if (rel.startsWith('..')) { + if (rel.startsWith('..') || isAbsolute(rel)) { res.writeHead(403, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: 'Path outside project root' })) return diff --git a/src/plugin/uiPlugin.ts b/src/plugin/uiPlugin.ts index ecc024b..b8f493b 100644 --- a/src/plugin/uiPlugin.ts +++ b/src/plugin/uiPlugin.ts @@ -1,5 +1,5 @@ import { existsSync, readFileSync, statSync } from 'node:fs' -import { resolve, relative, dirname, extname } from 'node:path' +import { resolve, relative, isAbsolute, dirname, extname } from 'node:path' import { fileURLToPath } from 'node:url' import type { Plugin } from 'vite' @@ -118,7 +118,7 @@ export function uiPlugin(): Plugin { // Security: ensure resolved path is within clientDir const relPath = relative(clientDir, filePath) - if (relPath.startsWith('..') || relPath.startsWith('/')) { + if (relPath.startsWith('..') || isAbsolute(relPath)) { res.writeHead(403) res.end() return