From 90afacd80649e04601f196de1b6c9f0404139697 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Mon, 24 Aug 2026 15:55:29 +0530 Subject: [PATCH 1/8] Ship CDN browser bundle with thin OpenUIDevtools host wrapper. Move the full widget UI into dist/devtools.browser.js and keep the npm package as a thin CDN loader that forwards host React and all props. --- docs/content/docs/api-reference/devtools.mdx | 22 +- docs/content/docs/api-reference/index.mdx | 4 +- packages/devtools/README.md | 21 +- packages/devtools/package.json | 3 +- packages/devtools/scripts/build-browser.mjs | 29 + packages/devtools/src/OpenUIDevtools.test.ts | 12 +- packages/devtools/src/OpenUIDevtools.tsx | 662 +----------------- .../devtools/src/OpenUIDevtoolsWidget.tsx | 607 ++++++++++++++++ .../devtools/src/browser-shims/jsx-runtime.ts | 12 + .../src/browser-shims/observability.ts | 16 + .../devtools/src/browser-shims/react-dom.ts | 8 + .../devtools/src/browser-shims/react-lang.ts | 14 + packages/devtools/src/browser-shims/react.ts | 17 + packages/devtools/src/browser-shims/slots.ts | 44 ++ packages/devtools/src/browser.ts | 74 ++ packages/devtools/src/cdn.ts | 70 ++ packages/devtools/src/index.ts | 2 +- packages/devtools/src/types.ts | 43 ++ pnpm-lock.yaml | 3 + 19 files changed, 1019 insertions(+), 644 deletions(-) create mode 100644 packages/devtools/scripts/build-browser.mjs create mode 100644 packages/devtools/src/OpenUIDevtoolsWidget.tsx create mode 100644 packages/devtools/src/browser-shims/jsx-runtime.ts create mode 100644 packages/devtools/src/browser-shims/observability.ts create mode 100644 packages/devtools/src/browser-shims/react-dom.ts create mode 100644 packages/devtools/src/browser-shims/react-lang.ts create mode 100644 packages/devtools/src/browser-shims/react.ts create mode 100644 packages/devtools/src/browser-shims/slots.ts create mode 100644 packages/devtools/src/browser.ts create mode 100644 packages/devtools/src/cdn.ts create mode 100644 packages/devtools/src/types.ts diff --git a/docs/content/docs/api-reference/devtools.mdx b/docs/content/docs/api-reference/devtools.mdx index 1377f6336..5ca022dda 100644 --- a/docs/content/docs/api-reference/devtools.mdx +++ b/docs/content/docs/api-reference/devtools.mdx @@ -11,7 +11,19 @@ Development-only UI widget for OpenUI apps. Renders a floating button that opens className="w-full rounded-lg border" /> -## Install +## Installation + +### CDN (via `react-lang`) + +If your app uses [`@openuidev/react-lang`](/docs/api-reference/react-lang), the widget shows up automatically in development. `react-lang` calls this package's thin helper, which fetches the browser build from jsDelivr (pinned to major `0`). + +#### CSP + +`script-src` must allow `cdn.jsdelivr.net` for the fetch to succeed. If it's blocked, the widget silently fails to appear – the rest of the app is unaffected. + +### Package Installation + +Install the package and render `` when you want custom props. The component is still a thin CDN wrapper — your props (`theme`, `position`, …) are forwarded into the fetched widget. A manually mounted instance always wins over the auto-mount: ```bash tab="pnpm" tab-group="pkg" pnpm add -D @openuidev/devtools @@ -40,7 +52,9 @@ function App() { return ( <> {/* your app */} - {process.env.NODE_ENV === "development" && } + {process.env.NODE_ENV === "development" && ( + + )} ); } @@ -53,7 +67,7 @@ function App() { | `enabled` | dev-only | Force the widget on/off. | | `position` | `"bottom-right"` | Corner for the toggle button: `top-left`/`top-right`/`bottom-*`. | | `maxEvents` | `50` | How many events to keep; oldest are dropped first. | -| `errorsOnly` | `true` | Capture only error/warning events, or every event. | +| `errorsOnly` | `false` | Initial state of the "errors only" display filter. | | `autoOpenOnError` | `true` | Initial state of the drawer's "auto-open on error" checkbox. | -| `bus` | shared singleton | An `Observability` instance to listen to. | | `theme` | `"light"` | `"light"` or `"dark"` theme for the drawer. | +| `cdnMajor` | `@latest` | Pin the CDN package major (`0` → `@0`). Omit for `@latest`. | diff --git a/docs/content/docs/api-reference/index.mdx b/docs/content/docs/api-reference/index.mdx index 9096c6513..c53c47cb1 100644 --- a/docs/content/docs/api-reference/index.mdx +++ b/docs/content/docs/api-reference/index.mdx @@ -25,7 +25,7 @@ The OpenUI SDK is split into packages that build on each other: - **`@openuidev/browser-bundle`** — Prebuilt browser bundle for CDN, iframe, and no-build integrations. It packages the renderer, UI library, React, ReactDOM, and styles into script-tag-friendly assets. -- **`@openuidev/devtools`** — Development-only floating widget that surfaces the events captured by `@openuidev/observability`, with error messages and stack traces. +- **`@openuidev/devtools`** — Development-only floating widget that surfaces the events captured by `@openuidev/observability`. Auto-mounts from a CDN in `react-lang` apps. - **`@openuidev/cli`** — Command-line tool for scaffolding new OpenUI chat apps and generating system prompts or JSON schemas from library definitions. @@ -97,7 +97,7 @@ The OpenUI SDK is split into packages that build on each other: CDN, iframe, and no-build browser bundle for the renderer, UI library, React, and styles. - Development-only floating widget surfacing captured events with error messages and stack traces. + Development-only floating widget. Auto-mounts from a CDN in development for react-lang apps. openui create (scaffold a Next.js app) and openui generate (system prompt + library spec from a diff --git a/packages/devtools/README.md b/packages/devtools/README.md index 517938bd3..12b336289 100644 --- a/packages/devtools/README.md +++ b/packages/devtools/README.md @@ -4,6 +4,10 @@ Development-only UI widget for OpenUI apps. Renders a floating button that opens ## Usage +If your app uses `@openuidev/react-lang`, the widget shows up automatically. `react-lang` loads this package's thin helper and fetches the browser build from a CDN (pinned to major `0`). + +You can also mount it yourself — the npm package is a thin wrapper that still fetches the CDN widget and injects your app's React / ReactDOM / react-lang. All props are forwarded into that widget: + ```tsx import { OpenUIDevtools } from "@openuidev/devtools"; @@ -11,15 +15,26 @@ function App() { return ( <> {/* your app */} - + ); } ``` +| Prop | Default | Notes | +| --- | --- | --- | +| `cdnMajor` | `@latest` | Pass `0` (etc.) to pin `https://cdn.jsdelivr.net/npm/@openuidev/devtools@0/...` | +| `theme`, `position`, `maxEvents`, `errorsOnly`, `autoOpenOnError`, `enabled` | see below | Forwarded into the CDN widget as-is | + +A manually mounted instance always wins over the auto-mount — only one instance ever renders. + +Publishing a new version of this package updates the CDN file on jsDelivr automatically (no separate CDN setup). The browser build is `dist/devtools.browser.js` inside the published tarball. + The widget renders nothing in production builds (`NODE_ENV === "production"`) unless `enabled` is passed explicitly. -`@openuidev/react-lang` ships with this package and auto-mounts the widget in development — no manual `` needed. Mounting it manually still works (e.g. to customize props): only one instance ever renders, and a manually mounted instance takes precedence over the auto-mounted one. +### CSP + +`script-src` must allow `cdn.jsdelivr.net` for the fetch to succeed. If it's blocked, the widget silently fails to appear — the rest of the app is unaffected. In development, `createLibrary()` registers the live library with the widget. A stream event's **Debug** button opens **OpenUI Debug** in its own tray — an editor against that library (host CSS included), with Render / Validation / Tree / JSON / Stream panels and simulated stream playback. @@ -35,4 +50,4 @@ Debug renders through the host's own `Renderer`. Its previews stay off the event | `errorsOnly` | `true` | Capture only error/warning events, or all. | | `autoOpenOnError` | `true` | Initial state of the "auto-open on error" setting. | | `theme` | `"light"` | Initial widget chrome theme: `"light"` or `"dark"` (Settings overrides). | -| `bus` | shared singleton | An `Observability` instance to listen to. | +| `cdnMajor` | `@latest` | Pin the CDN package major (`0` → `@0`). Omit for `@latest`. | diff --git a/packages/devtools/package.json b/packages/devtools/package.json index da779953b..b2baa8838 100644 --- a/packages/devtools/package.json +++ b/packages/devtools/package.json @@ -26,7 +26,7 @@ }, "scripts": { "test": "vitest run --passWithNoTests", - "build": "tsdown", + "build": "tsdown && node scripts/build-browser.mjs", "watch": "tsdown --watch", "typecheck": "tsc --noEmit", "lint:check": "eslint ./src", @@ -74,6 +74,7 @@ "@types/node": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", + "esbuild": "^0.25.12", "jsdom": "catalog:", "react": "catalog:", "react-dom": "catalog:", diff --git a/packages/devtools/scripts/build-browser.mjs b/packages/devtools/scripts/build-browser.mjs new file mode 100644 index 000000000..4b767fd71 --- /dev/null +++ b/packages/devtools/scripts/build-browser.mjs @@ -0,0 +1,29 @@ +import { build } from "esbuild"; +import { fileURLToPath } from "node:url"; + +// This build bundles lucide-react in and aliases the rest to +// browser-shims/*. +const shim = (name) => fileURLToPath(new URL(`../src/browser-shims/${name}.ts`, import.meta.url)); + +await build({ + entryPoints: [fileURLToPath(new URL("../src/browser.ts", import.meta.url))], + outfile: fileURLToPath(new URL("../dist/devtools.browser.js", import.meta.url)), + bundle: true, + format: "esm", + target: "es2022", + minify: true, + sourcemap: true, + jsx: "automatic", + define: { + "process.env.NODE_ENV": '"development"', + }, + alias: { + react: shim("react"), + "react-dom": shim("react-dom"), + "react/jsx-runtime": shim("jsx-runtime"), + "@openuidev/observability": shim("observability"), + "@openuidev/react-lang": shim("react-lang"), + }, +}); + +console.log("wrote dist/devtools.browser.js"); diff --git a/packages/devtools/src/OpenUIDevtools.test.ts b/packages/devtools/src/OpenUIDevtools.test.ts index f2492ec26..b6afcc42c 100644 --- a/packages/devtools/src/OpenUIDevtools.test.ts +++ b/packages/devtools/src/OpenUIDevtools.test.ts @@ -3,7 +3,7 @@ import { observability, toErrorInfo } from "@openuidev/observability"; import { act, createElement } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { OpenUIDevtools, type OpenUIDevtoolsProps } from "./index"; +import { OpenUIDevtoolsWidget, type OpenUIDevtoolsWidgetProps } from "./OpenUIDevtoolsWidget"; vi.mock("@openuidev/react-lang", async () => { const { createElement: el } = await import("react"); @@ -79,8 +79,8 @@ afterEach(() => { container.remove(); }); -function render(props: OpenUIDevtoolsProps): void { - act(() => root.render(createElement(OpenUIDevtools, props))); +function render(props: OpenUIDevtoolsWidgetProps): void { + act(() => root.render(createElement(OpenUIDevtoolsWidget, props))); } /** The floating toggle button. */ @@ -177,7 +177,7 @@ function checkboxLabeled(text: string): HTMLInputElement { return input; } -function remount(props: OpenUIDevtoolsProps): void { +function remount(props: OpenUIDevtoolsWidgetProps): void { act(() => root.unmount()); root = createRoot(container); render(props); @@ -493,7 +493,7 @@ describe("OpenUIDevtools", () => { const secondRoot = createRoot(second); render({ enabled: true }); - act(() => secondRoot.render(createElement(OpenUIDevtools, { enabled: true }))); + act(() => secondRoot.render(createElement(OpenUIDevtoolsWidget, { enabled: true }))); expect(document.querySelectorAll('button[aria-label="Open OpenUI Inspect"]')).toHaveLength(1); @@ -510,7 +510,7 @@ describe("OpenUIDevtools", () => { const manual = document.createElement("div"); document.body.appendChild(manual); const manualRoot = createRoot(manual); - act(() => manualRoot.render(createElement(OpenUIDevtools, { enabled: true }))); + act(() => manualRoot.render(createElement(OpenUIDevtoolsWidget, { enabled: true }))); expect(container.querySelector('button[aria-label="Open OpenUI Inspect"]')).toBeNull(); expect(manual.querySelector('button[aria-label="Open OpenUI Inspect"]')).not.toBeNull(); diff --git a/packages/devtools/src/OpenUIDevtools.tsx b/packages/devtools/src/OpenUIDevtools.tsx index fcbc204df..7c97b7263 100644 --- a/packages/devtools/src/OpenUIDevtools.tsx +++ b/packages/devtools/src/OpenUIDevtools.tsx @@ -1,638 +1,46 @@ "use client"; -import { observability, type ObservabilityEvent } from "@openuidev/observability"; -import { Inbox, RotateCcw, Settings, X } from "lucide-react"; -import { useEffect, useRef, useState, type CSSProperties } from "react"; -import { DEFAULT_EDITOR_PCT, useDebug } from "./debug"; -import { - EventRow, - getQuotaError, - getReactLangStreamDetail, - QuotaErrorRow, - ReactLangStreamEventRow, -} from "./inspect"; -import { - addOrReplaceEvent, - isLibraryEvent, - useDevtoolsConfig, - useDevtoolsSingleton, - type DevtoolsConfig, -} from "./lib"; -import { - DEFAULT_COLOR_MODE, - DevtoolsModeProvider, - FONT, - rootStyle, - theme, - useStyles, - type ColorMode, - type ThemeTokens, -} from "./theme"; -import { ErrorBoundary, IconButton, ShiroLogo, ThemeSegmented } from "./ui"; -import ReliabilityBanner from "./ui/ReliabilityBanner"; +import { useEffect } from "react"; +import { mountOpenUIDevtoolsFromCdn } from "./cdn"; +import type { OpenUIDevtoolsProps } from "./types"; -/** Uniform row height for the settings menu, set by its tallest control. */ -const MENU_ROW_HEIGHT = 28; +export type { ColorMode, DevtoolsPosition, OpenUIDevtoolsProps } from "./types"; /** - * Tray geometry. The two trays together fill a block anchored to the bottom - * right — 85% of the viewport, capped so it stops growing on very large - * displays. Inspect keeps a fixed width and Debug takes whatever is left, - * overlapping Inspect rather than collapsing once it hits its floor. + * Development-only OpenUI Inspect / Debug widget. + * + * The npm package is a thin host wrapper: in development it fetches the CDN + * browser build and mounts it with this app's React, ReactDOM, and react-lang. + * All props (`theme`, `position`, `maxEvents`, …) are forwarded into that + * widget. Pass `cdnMajor` to pin a protocol major (`0` → `@0`); omit it for + * `@latest`. + * + * Renders nothing itself — the widget attaches to `document.body`. */ -const TRAY_EDGE = 12; -const TRAY_GAP = 12; -const INSPECT_WIDTH = 480; -const DEBUG_MIN_WIDTH = 360; -const BLOCK_W = `min(85vw, 3456px)`; -const BLOCK_H = `min(85vh, 2234px)`; +export function OpenUIDevtools(props: OpenUIDevtoolsProps) { + const { + enabled, + position, + maxEvents, + errorsOnly, + autoOpenOnError, + theme, + cdnMajor, + __autoMounted, + } = props; -export type DevtoolsPosition = "top-left" | "top-right" | "bottom-left" | "bottom-right"; - -export interface OpenUIDevtoolsProps { - /** Force the widget on/off. Defaults to on outside production builds. */ - enabled?: boolean; - /** Corner for the floating toggle button. Defaults to "bottom-right". */ - position?: DevtoolsPosition; - /** How many events to keep; oldest are dropped first. */ - maxEvents?: number; - /** Initial state of the drawer's "errors only" display filter: only - * error/warning events (default) or every event. */ - errorsOnly?: boolean; - /** Initial state of the drawer's "auto-open on error" checkbox. Defaults to true. */ - autoOpenOnError?: boolean; - /** - * Widget UI theme. If passed, it wins over the stored Settings choice - * and is written to config. Otherwise the stored theme is used, then light. - * Never auto-detected from the host page or the OS. - */ - theme?: ColorMode; - /** - * @internal Set by react-lang's auto-mount. Auto-mounted instances yield to - * any manually rendered so host-provided props win. - */ - __autoMounted?: boolean; -} - -/** - * dev-only widget that surfaces events captured by `@openuidev/observability` — - * a Shiro-logo button (which turns red with the error count) that opens a side - * drawer listing every captured event. Errors expand in place to show the - * stack trace. OpenUI Inspect and OpenUI Debug are independent tools on - * independent trays: a stream's Debug button opens Debug beside Inspect, and - * either closes without disturbing the other. Display filters and the theme - * live in the header settings menu. Renders nothing in production unless - * `enabled` is set explicitly. - */ -export function OpenUIDevtools({ - enabled, - position = "bottom-right", - maxEvents = 50, - errorsOnly = false, - autoOpenOnError = true, - theme: themeProp, - __autoMounted = false, -}: OpenUIDevtoolsProps) { - const isEnabled = - enabled ?? (typeof process === "undefined" || process.env["NODE_ENV"] !== "production"); - // Only one instance renders even when several are mounted (e.g. react-lang's - // auto-mount plus a manual in the host's layout). - const isSingleton = useDevtoolsSingleton(__autoMounted); - const [events, setEvents] = useState([]); - const [open, setOpen] = useState(false); - const [toggleHovered, setToggleHovered] = useState(false); - const { config, setConfig, configRef } = useDevtoolsConfig( - { - autoOpen: autoOpenOnError, - onlyErrors: errorsOnly, - theme: DEFAULT_COLOR_MODE, - helpSeen: false, - editorPct: DEFAULT_EDITOR_PCT, - }, - { theme: themeProp }, - ); - const { onlyErrors, theme: mode } = config; - const debug = useDebug({ - theme: mode, - helpSeen: config.helpSeen, - editorPct: config.editorPct, - setConfig, - }); - const styles = uiStyles(theme(mode)); - - // Read configRef inside the (stable) subscription without re-subscribing. useEffect(() => { - if (!isEnabled) return; - return observability.listenAll((event) => { - if (isLibraryEvent(event)) return; - setEvents((prev) => addOrReplaceEvent(prev, event, maxEvents)); - if (event.level === "error" && configRef.current.autoOpen) setOpen(true); + return mountOpenUIDevtoolsFromCdn({ + enabled, + position, + maxEvents, + errorsOnly, + autoOpenOnError, + theme, + cdnMajor, + __autoMounted, }); - }, [isEnabled, maxEvents, configRef]); - - // Escape dismisses the top tray first: Debug → Inspect → closed. The - // settings menu handles its own Escape first (capture phase), so it never - // falls through to here. - useEffect(() => { - if (!open && !debug.trayOpen) return; - const onKeyDown = (event: KeyboardEvent) => { - if (event.key !== "Escape") return; - if (debug.trayOpen) debug.retract(); - else setOpen(false); - }; - document.addEventListener("keydown", onKeyDown); - return () => document.removeEventListener("keydown", onKeyDown); - }, [open, debug.trayOpen, debug.retract]); - - if (!isEnabled || !isSingleton) return null; - - const errorCount = events.filter((event) => event.level === "error").length; - const visibleEvents = onlyErrors ? events.filter((event) => event.level !== "info") : events; - - // Inspect is pinned to the right edge; Debug fills the rest of the block and - // slides over to reclaim Inspect's slot whenever Inspect is out. - const inspectSlot = open ? INSPECT_WIDTH + TRAY_GAP : 0; - const inspectTray: CSSProperties = { - right: TRAY_EDGE, - bottom: TRAY_EDGE, - height: BLOCK_H, - width: `min(${INSPECT_WIDTH}px, calc(100vw - ${TRAY_EDGE * 2}px))`, - transform: open ? "translateX(0)" : `translateX(calc(100% + ${TRAY_EDGE}px))`, - }; - // Debug is a workspace rather than a peek at the app behind it: it fills - // everything Inspect leaves (left/right edges rather than a width, so the - // inset matches top and bottom) and cuts straight in instead of sliding. - // Overrides the shared UI, so it is spread last. - const debugTray: CSSProperties = { - right: TRAY_EDGE + inspectSlot, - bottom: TRAY_EDGE, - height: BLOCK_H, - width: `max(${DEBUG_MIN_WIDTH}px, calc(${BLOCK_W} - ${inspectSlot}px))`, - transform: "none", - transition: "none", - visibility: debug.trayOpen ? "visible" : "hidden", - }; - - return ( - -
- -
- - {/* Kept mounted so open/close can transition; hidden + inert when closed. */} - - - - {debug.portal} -
- ); -} - -/** - * A real checkbox painted as a switch — the input stays in the tree (hidden but - * clickable and focusable) so keyboard, form semantics, and screen readers get - * the native control rather than a div pretending to be one. - */ -function SettingSwitch({ - label, - checked, - onChange, -}: { - label: string; - checked: boolean; - onChange: (checked: boolean) => void; -}) { - const styles = useStyles(uiStyles); - return ( - - ); -} - -/** - * Header dropdown for the display filters and the widget theme, so the list is - * all list. Escape is handled in the capture phase so closing the menu doesn't - * also step the drawer back. - */ -function SettingsMenu({ - config, - onChange, -}: { - config: DevtoolsConfig; - onChange: (patch: Partial) => void; -}) { - const [open, setOpen] = useState(false); - const styles = useStyles(uiStyles); - const wrap = useRef(null); - - useEffect(() => { - if (!open) return; - const onPointerDown = (event: MouseEvent) => { - if (!wrap.current?.contains(event.target as Node)) setOpen(false); - }; - const onKeyDown = (event: KeyboardEvent) => { - if (event.key !== "Escape") return; - event.stopPropagation(); - setOpen(false); - }; - document.addEventListener("mousedown", onPointerDown); - document.addEventListener("keydown", onKeyDown, true); - return () => { - document.removeEventListener("mousedown", onPointerDown); - document.removeEventListener("keydown", onKeyDown, true); - }; - }, [open]); - - return ( -
- setOpen((current) => !current)} - aria-label="Devtools settings" - aria-haspopup="true" - aria-expanded={open} - title="Settings" - > - - - {open ? ( -
- {/* Every row reads the same way: name on the left, control on the - right, hairline between. */} - onChange({ autoOpen })} - /> -
- onChange({ onlyErrors })} - /> -
-
- Theme - onChange({ theme })} /> -
-
- ) : null} -
- ); -} - -const positionStyles: Record = { - "top-left": { top: 16, left: 16 }, - "top-right": { top: 16, right: 16 }, - "bottom-left": { bottom: 16, left: 16 }, - "bottom-right": { bottom: 16, right: 16 }, -}; + }, [enabled, position, maxEvents, errorsOnly, autoOpenOnError, theme, cdnMajor, __autoMounted]); -function uiStyles(t: ThemeTokens) { - return { - toggleWrap: { - position: "fixed", - // Max 32-bit signed int — sit above any app UI. - zIndex: 2147483647, - }, - toggle: { - boxSizing: "border-box", - width: 40, - height: 40, - display: "flex", - alignItems: "center", - justifyContent: "center", - borderRadius: "50%", - borderWidth: 1, - borderStyle: "solid", - borderColor: t.toggleBorder, - background: t.toggleBg, - color: t.toggleFg, - cursor: "pointer", - boxShadow: t.toggleShadow, - fontFamily: FONT, - padding: 0, - transition: "background 150ms ease, box-shadow 150ms ease, transform 150ms ease", - }, - toggleHover: { - transform: "scale(1.08)", - }, - // Errors swap the mark for a count on a red disc, held inside a light puck so - // the number reads as a badge rather than flooding the whole button red. - toggleError: { - background: t.toggleErrorSurface, - borderColor: t.toggleErrorRing, - }, - toggleCount: { - display: "inline-flex", - alignItems: "center", - justifyContent: "center", - boxSizing: "border-box", - minWidth: 24, - height: 24, - padding: "0 6px", - borderRadius: 999, - background: t.toggleError, - color: "#fff", - fontSize: 13, - fontWeight: 700, - lineHeight: 1, - }, - // Geometry (right/width/transform) is per-tray and set inline; this is the - // shared UI. Each tray hides itself when closed so the other can stay open - // without a retracted tray remaining focusable. - drawer: { - position: "fixed", - // Max 32-bit signed int — sit above any app UI, including the toggle. - zIndex: 2147483647, - boxSizing: "border-box", - display: "flex", - flexDirection: "column", - borderWidth: 1, - borderStyle: "solid", - borderColor: t.trayRing, - borderRadius: 16, - background: t.bg, - boxShadow: t.trayShadow, - color: t.fg, - fontFamily: FONT, - fontSize: 13, - visibility: "hidden", - transition: - "transform 220ms cubic-bezier(0.32, 0.72, 0, 1), right 260ms cubic-bezier(0.32, 0.72, 0, 1), width 260ms cubic-bezier(0.32, 0.72, 0, 1), visibility 0s linear 220ms", - overflow: "hidden", - }, - drawerOpen: { - visibility: "visible", - transition: - "transform 220ms cubic-bezier(0.32, 0.72, 0, 1), right 260ms cubic-bezier(0.32, 0.72, 0, 1), width 260ms cubic-bezier(0.32, 0.72, 0, 1), visibility 0s", - }, - debugHost: { - flex: 1, - minHeight: 0, - }, - header: { - display: "flex", - justifyContent: "space-between", - alignItems: "center", - gap: 8, - padding: "12px 16px", - fontWeight: 600, - fontSize: 14, - }, - headerLeft: { - display: "flex", - alignItems: "center", - gap: 6, - minWidth: 0, - }, - title: { - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", - }, - headerActions: { - display: "flex", - alignItems: "center", - gap: 6, - flexShrink: 0, - }, - menuWrap: { - position: "relative", - display: "inline-flex", - }, - menu: { - position: "absolute", - top: "calc(100% + 6px)", - right: 0, - // Above the banner group, which lifts itself over the list for its fade. - zIndex: 2, - boxSizing: "border-box", - width: 236, - display: "flex", - flexDirection: "column", - gap: 10, - border: `1px solid ${t.border}`, - borderRadius: 12, - background: t.bg, - boxShadow: t.shadow, - padding: 12, - fontWeight: 400, - }, - // Every row is the height of the tallest control (the theme toggle), so the - // dividers land on an even rhythm no matter what each row holds. - menuCheckbox: { - display: "flex", - alignItems: "center", - justifyContent: "space-between", - gap: 12, - minHeight: MENU_ROW_HEIGHT, - cursor: "pointer", - }, - switchTrack: { - position: "relative", - boxSizing: "border-box", - flexShrink: 0, - width: 30, - height: 18, - borderRadius: 999, - background: t.border, - transition: "background 150ms ease", - }, - switchTrackOn: { - background: t.inverted, - }, - // Covers the whole track so the hit area and focus ring stay on the input. - switchInput: { - position: "absolute", - inset: 0, - width: "100%", - height: "100%", - margin: 0, - borderRadius: 999, - opacity: 0, - cursor: "pointer", - }, - switchKnob: { - position: "absolute", - top: 2, - left: 2, - width: 14, - height: 14, - borderRadius: "50%", - background: t.bg, - boxShadow: t.shadowSubtle, - transition: "transform 150ms ease", - pointerEvents: "none", - }, - switchKnobOn: { - transform: "translateX(12px)", - }, - menuDivider: { - height: 1, - background: t.borderSubtle, - }, - menuRow: { - display: "flex", - alignItems: "center", - justifyContent: "space-between", - gap: 12, - minHeight: MENU_ROW_HEIGHT, - }, - menuLabel: { - fontSize: 12, - fontWeight: 500, - color: t.fg, - }, - // Mirrors bannerFade at the tray's bottom edge, so rows dissolve into the - // drawer instead of meeting the border mid-row. Pinned to the tray rather - // than the list, so it covers the stack-trace view too. - trayFade: { - position: "absolute", - left: 0, - right: 0, - bottom: 0, - height: 28, - background: `linear-gradient(to top, ${t.bg}, transparent)`, - pointerEvents: "none", - }, - list: { - flex: 1, - minHeight: 0, - overflowY: "auto", - padding: 12, - display: "flex", - flexDirection: "column", - gap: 10, - }, - // Fills the list so the message sits in the middle of the tray, not pinned - // under the header. - empty: { - flex: 1, - minHeight: 0, - display: "flex", - flexDirection: "column", - alignItems: "center", - justifyContent: "center", - gap: 12, - color: t.fgFaint, - textAlign: "center", - }, - emptyIcon: { - display: "inline-flex", - alignItems: "center", - justifyContent: "center", - }, - } satisfies Record; + return null; } diff --git a/packages/devtools/src/OpenUIDevtoolsWidget.tsx b/packages/devtools/src/OpenUIDevtoolsWidget.tsx new file mode 100644 index 000000000..2ccc902c2 --- /dev/null +++ b/packages/devtools/src/OpenUIDevtoolsWidget.tsx @@ -0,0 +1,607 @@ +"use client"; + +import { observability, type ObservabilityEvent } from "@openuidev/observability"; +import { Inbox, RotateCcw, Settings, X } from "lucide-react"; +import { useEffect, useRef, useState, type CSSProperties } from "react"; +import { DEFAULT_EDITOR_PCT, useDebug } from "./debug"; +import { + EventRow, + getQuotaError, + getReactLangStreamDetail, + QuotaErrorRow, + ReactLangStreamEventRow, +} from "./inspect"; +import { + addOrReplaceEvent, + isLibraryEvent, + useDevtoolsConfig, + useDevtoolsSingleton, + type DevtoolsConfig, +} from "./lib"; +import { + DEFAULT_COLOR_MODE, + DevtoolsModeProvider, + FONT, + rootStyle, + theme, + useStyles, + type ThemeTokens, +} from "./theme"; +import type { OpenUIDevtoolsWidgetProps } from "./types"; +import { ErrorBoundary, IconButton, ShiroLogo, ThemeSegmented } from "./ui"; +import ReliabilityBanner from "./ui/ReliabilityBanner"; + +export type { DevtoolsPosition, OpenUIDevtoolsProps, OpenUIDevtoolsWidgetProps } from "./types"; + +/** Uniform row height for the settings menu, set by its tallest control. */ +const MENU_ROW_HEIGHT = 28; + +/** + * Tray geometry. The two trays together fill a block anchored to the bottom + * right — 85% of the viewport, capped so it stops growing on very large + * displays. Inspect keeps a fixed width and Debug takes whatever is left, + * overlapping Inspect rather than collapsing once it hits its floor. + */ +const TRAY_EDGE = 12; +const TRAY_GAP = 12; +const INSPECT_WIDTH = 480; +const DEBUG_MIN_WIDTH = 360; +const BLOCK_W = `min(85vw, 3456px)`; +const BLOCK_H = `min(85vh, 2234px)`; + +/** + * Full Inspect/Debug UI. Only loaded from the CDN browser bundle after the + * host has injected React into the slot — not from the npm package entry. + */ +export function OpenUIDevtoolsWidget({ + enabled, + position = "bottom-right", + maxEvents = 50, + errorsOnly = false, + autoOpenOnError = true, + theme: themeProp, + __autoMounted = false, +}: OpenUIDevtoolsWidgetProps) { + const isEnabled = + enabled ?? (typeof process === "undefined" || process.env["NODE_ENV"] !== "production"); + // Only one instance renders even when several are mounted (e.g. react-lang's + // auto-mount plus a manual in the host's layout). + const isSingleton = useDevtoolsSingleton(__autoMounted); + const [events, setEvents] = useState([]); + const [open, setOpen] = useState(false); + const [toggleHovered, setToggleHovered] = useState(false); + const { config, setConfig, configRef } = useDevtoolsConfig( + { + autoOpen: autoOpenOnError, + onlyErrors: errorsOnly, + theme: DEFAULT_COLOR_MODE, + helpSeen: false, + editorPct: DEFAULT_EDITOR_PCT, + }, + { theme: themeProp }, + ); + const { onlyErrors, theme: mode } = config; + const debug = useDebug({ + theme: mode, + helpSeen: config.helpSeen, + editorPct: config.editorPct, + setConfig, + }); + const styles = uiStyles(theme(mode)); + + // Read configRef inside the (stable) subscription without re-subscribing. + useEffect(() => { + if (!isEnabled) return; + return observability.listenAll((event) => { + if (isLibraryEvent(event)) return; + setEvents((prev) => addOrReplaceEvent(prev, event, maxEvents)); + if (event.level === "error" && configRef.current.autoOpen) setOpen(true); + }); + }, [isEnabled, maxEvents, configRef]); + + // Escape dismisses the top tray first: Debug → Inspect → closed. The + // settings menu handles its own Escape first (capture phase), so it never + // falls through to here. + useEffect(() => { + if (!open && !debug.trayOpen) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + if (debug.trayOpen) debug.retract(); + else setOpen(false); + }; + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [open, debug.trayOpen, debug.retract]); + + if (!isEnabled || !isSingleton) return null; + + const errorCount = events.filter((event) => event.level === "error").length; + const visibleEvents = onlyErrors ? events.filter((event) => event.level !== "info") : events; + + // Inspect is pinned to the right edge; Debug fills the rest of the block and + // slides over to reclaim Inspect's slot whenever Inspect is out. + const inspectSlot = open ? INSPECT_WIDTH + TRAY_GAP : 0; + const inspectTray: CSSProperties = { + right: TRAY_EDGE, + bottom: TRAY_EDGE, + height: BLOCK_H, + width: `min(${INSPECT_WIDTH}px, calc(100vw - ${TRAY_EDGE * 2}px))`, + transform: open ? "translateX(0)" : `translateX(calc(100% + ${TRAY_EDGE}px))`, + }; + // Debug is a workspace rather than a peek at the app behind it: it fills + // everything Inspect leaves (left/right edges rather than a width, so the + // inset matches top and bottom) and cuts straight in instead of sliding. + // Overrides the shared UI, so it is spread last. + const debugTray: CSSProperties = { + right: TRAY_EDGE + inspectSlot, + bottom: TRAY_EDGE, + height: BLOCK_H, + width: `max(${DEBUG_MIN_WIDTH}px, calc(${BLOCK_W} - ${inspectSlot}px))`, + transform: "none", + transition: "none", + visibility: debug.trayOpen ? "visible" : "hidden", + }; + + return ( + +
+ +
+ + {/* Kept mounted so open/close can transition; hidden + inert when closed. */} + + + + {debug.portal} +
+ ); +} + +/** + * A real checkbox painted as a switch — the input stays in the tree (hidden but + * clickable and focusable) so keyboard, form semantics, and screen readers get + * the native control rather than a div pretending to be one. + */ +function SettingSwitch({ + label, + checked, + onChange, +}: { + label: string; + checked: boolean; + onChange: (checked: boolean) => void; +}) { + const styles = useStyles(uiStyles); + return ( + + ); +} + +/** + * Header dropdown for the display filters and the widget theme, so the list is + * all list. Escape is handled in the capture phase so closing the menu doesn't + * also step the drawer back. + */ +function SettingsMenu({ + config, + onChange, +}: { + config: DevtoolsConfig; + onChange: (patch: Partial) => void; +}) { + const [open, setOpen] = useState(false); + const styles = useStyles(uiStyles); + const wrap = useRef(null); + + useEffect(() => { + if (!open) return; + const onPointerDown = (event: MouseEvent) => { + if (!wrap.current?.contains(event.target as Node)) setOpen(false); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + event.stopPropagation(); + setOpen(false); + }; + document.addEventListener("mousedown", onPointerDown); + document.addEventListener("keydown", onKeyDown, true); + return () => { + document.removeEventListener("mousedown", onPointerDown); + document.removeEventListener("keydown", onKeyDown, true); + }; + }, [open]); + + return ( +
+ setOpen((current) => !current)} + aria-label="Devtools settings" + aria-haspopup="true" + aria-expanded={open} + title="Settings" + > + + + {open ? ( +
+ {/* Every row reads the same way: name on the left, control on the + right, hairline between. */} + onChange({ autoOpen })} + /> +
+ onChange({ onlyErrors })} + /> +
+
+ Theme + onChange({ theme })} /> +
+
+ ) : null} +
+ ); +} + +const positionStyles: Record = { + "top-left": { top: 16, left: 16 }, + "top-right": { top: 16, right: 16 }, + "bottom-left": { bottom: 16, left: 16 }, + "bottom-right": { bottom: 16, right: 16 }, +}; + +function uiStyles(t: ThemeTokens) { + return { + toggleWrap: { + position: "fixed", + // Max 32-bit signed int — sit above any app UI. + zIndex: 2147483647, + }, + toggle: { + boxSizing: "border-box", + width: 40, + height: 40, + display: "flex", + alignItems: "center", + justifyContent: "center", + borderRadius: "50%", + borderWidth: 1, + borderStyle: "solid", + borderColor: t.toggleBorder, + background: t.toggleBg, + color: t.toggleFg, + cursor: "pointer", + boxShadow: t.toggleShadow, + fontFamily: FONT, + padding: 0, + transition: "background 150ms ease, box-shadow 150ms ease, transform 150ms ease", + }, + toggleHover: { + transform: "scale(1.08)", + }, + // Errors swap the mark for a count on a red disc, held inside a light puck so + // the number reads as a badge rather than flooding the whole button red. + toggleError: { + background: t.toggleErrorSurface, + borderColor: t.toggleErrorRing, + }, + toggleCount: { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + boxSizing: "border-box", + minWidth: 24, + height: 24, + padding: "0 6px", + borderRadius: 999, + background: t.toggleError, + color: "#fff", + fontSize: 13, + fontWeight: 700, + lineHeight: 1, + }, + // Geometry (right/width/transform) is per-tray and set inline; this is the + // shared UI. Each tray hides itself when closed so the other can stay open + // without a retracted tray remaining focusable. + drawer: { + position: "fixed", + // Max 32-bit signed int — sit above any app UI, including the toggle. + zIndex: 2147483647, + boxSizing: "border-box", + display: "flex", + flexDirection: "column", + borderWidth: 1, + borderStyle: "solid", + borderColor: t.trayRing, + borderRadius: 16, + background: t.bg, + boxShadow: t.trayShadow, + color: t.fg, + fontFamily: FONT, + fontSize: 13, + visibility: "hidden", + transition: + "transform 220ms cubic-bezier(0.32, 0.72, 0, 1), right 260ms cubic-bezier(0.32, 0.72, 0, 1), width 260ms cubic-bezier(0.32, 0.72, 0, 1), visibility 0s linear 220ms", + overflow: "hidden", + }, + drawerOpen: { + visibility: "visible", + transition: + "transform 220ms cubic-bezier(0.32, 0.72, 0, 1), right 260ms cubic-bezier(0.32, 0.72, 0, 1), width 260ms cubic-bezier(0.32, 0.72, 0, 1), visibility 0s", + }, + debugHost: { + flex: 1, + minHeight: 0, + }, + header: { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + gap: 8, + padding: "12px 16px", + fontWeight: 600, + fontSize: 14, + }, + headerLeft: { + display: "flex", + alignItems: "center", + gap: 6, + minWidth: 0, + }, + title: { + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }, + headerActions: { + display: "flex", + alignItems: "center", + gap: 6, + flexShrink: 0, + }, + menuWrap: { + position: "relative", + display: "inline-flex", + }, + menu: { + position: "absolute", + top: "calc(100% + 6px)", + right: 0, + // Above the banner group, which lifts itself over the list for its fade. + zIndex: 2, + boxSizing: "border-box", + width: 236, + display: "flex", + flexDirection: "column", + gap: 10, + border: `1px solid ${t.border}`, + borderRadius: 12, + background: t.bg, + boxShadow: t.shadow, + padding: 12, + fontWeight: 400, + }, + // Every row is the height of the tallest control (the theme toggle), so the + // dividers land on an even rhythm no matter what each row holds. + menuCheckbox: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: 12, + minHeight: MENU_ROW_HEIGHT, + cursor: "pointer", + }, + switchTrack: { + position: "relative", + boxSizing: "border-box", + flexShrink: 0, + width: 30, + height: 18, + borderRadius: 999, + background: t.border, + transition: "background 150ms ease", + }, + switchTrackOn: { + background: t.inverted, + }, + // Covers the whole track so the hit area and focus ring stay on the input. + switchInput: { + position: "absolute", + inset: 0, + width: "100%", + height: "100%", + margin: 0, + borderRadius: 999, + opacity: 0, + cursor: "pointer", + }, + switchKnob: { + position: "absolute", + top: 2, + left: 2, + width: 14, + height: 14, + borderRadius: "50%", + background: t.bg, + boxShadow: t.shadowSubtle, + transition: "transform 150ms ease", + pointerEvents: "none", + }, + switchKnobOn: { + transform: "translateX(12px)", + }, + menuDivider: { + height: 1, + background: t.borderSubtle, + }, + menuRow: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: 12, + minHeight: MENU_ROW_HEIGHT, + }, + menuLabel: { + fontSize: 12, + fontWeight: 500, + color: t.fg, + }, + // Mirrors bannerFade at the tray's bottom edge, so rows dissolve into the + // drawer instead of meeting the border mid-row. Pinned to the tray rather + // than the list, so it covers the stack-trace view too. + trayFade: { + position: "absolute", + left: 0, + right: 0, + bottom: 0, + height: 28, + background: `linear-gradient(to top, ${t.bg}, transparent)`, + pointerEvents: "none", + }, + list: { + flex: 1, + minHeight: 0, + overflowY: "auto", + padding: 12, + display: "flex", + flexDirection: "column", + gap: 10, + }, + // Fills the list so the message sits in the middle of the tray, not pinned + // under the header. + empty: { + flex: 1, + minHeight: 0, + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + gap: 12, + color: t.fgFaint, + textAlign: "center", + }, + emptyIcon: { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + }, + } satisfies Record; +} diff --git a/packages/devtools/src/browser-shims/jsx-runtime.ts b/packages/devtools/src/browser-shims/jsx-runtime.ts new file mode 100644 index 000000000..ae45ee19a --- /dev/null +++ b/packages/devtools/src/browser-shims/jsx-runtime.ts @@ -0,0 +1,12 @@ +// React itself doesn't export jsx/jsxs, they only live in this subpath. +import { requireReact } from "./slots"; + +const react = requireReact(); + +export const Fragment = react.Fragment; + +export function jsx(type: unknown, props: Record, key?: string) { + return react.createElement(type as never, key === undefined ? props : { ...props, key }); +} + +export const jsxs = jsx; diff --git a/packages/devtools/src/browser-shims/observability.ts b/packages/devtools/src/browser-shims/observability.ts new file mode 100644 index 000000000..bc9f8d5dd --- /dev/null +++ b/packages/devtools/src/browser-shims/observability.ts @@ -0,0 +1,16 @@ +// Stands in for "@openuidev/observability". Never a second bus: mount() +// resolves the host's bus (Symbol.for("openui.observability"), or an +// explicit opts.bus) before this module loads, and refuses to mount at all +// if neither is present +import { slot } from "./slots"; + +export const observability = slot.bus!; + +export type { + Observability, + ObservabilityDetail, + ObservabilityErrorInfo, + ObservabilityEvent, + ObservabilityLevel, + Remove, +} from "@openuidev/observability"; diff --git a/packages/devtools/src/browser-shims/react-dom.ts b/packages/devtools/src/browser-shims/react-dom.ts new file mode 100644 index 000000000..e25a9d5d7 --- /dev/null +++ b/packages/devtools/src/browser-shims/react-dom.ts @@ -0,0 +1,8 @@ +// Stands in for the bare "react-dom" specifier (OpenUIDevtools.tsx's +// createPortal, used to eject Debug into its own popup window). Same +// load-order guarantee as browser-shims/react.ts. +import { requireReactDOM } from "./slots"; + +const reactDOM = requireReactDOM(); + +export const { createPortal } = reactDOM; diff --git a/packages/devtools/src/browser-shims/react-lang.ts b/packages/devtools/src/browser-shims/react-lang.ts new file mode 100644 index 000000000..99dd43d17 --- /dev/null +++ b/packages/devtools/src/browser-shims/react-lang.ts @@ -0,0 +1,14 @@ +import { requireLoadReactLang } from "./slots"; + +const mod = (await requireLoadReactLang()) as + | { + Renderer?: unknown; + createParser?: unknown; + createStreamingParser?: unknown; + } + | null + | undefined; + +export const Renderer = mod?.Renderer; +export const createParser = mod?.createParser; +export const createStreamingParser = mod?.createStreamingParser; diff --git a/packages/devtools/src/browser-shims/react.ts b/packages/devtools/src/browser-shims/react.ts new file mode 100644 index 000000000..ef7f14593 --- /dev/null +++ b/packages/devtools/src/browser-shims/react.ts @@ -0,0 +1,17 @@ +import { requireReact } from "./slots"; + +const react = requireReact(); + +export const { + useState, + useEffect, + useCallback, + useMemo, + useRef, + useContext, + createContext, + createElement, + forwardRef, + Component, + Fragment, +} = react; diff --git a/packages/devtools/src/browser-shims/slots.ts b/packages/devtools/src/browser-shims/slots.ts new file mode 100644 index 000000000..db8bc2e5e --- /dev/null +++ b/packages/devtools/src/browser-shims/slots.ts @@ -0,0 +1,44 @@ +import type { Observability } from "@openuidev/observability"; + +/** + * Mutable slot filled by `mountOpenUIDevtools()` before the widget's module + * graph is loaded. The browser build has no bundler to resolve `react` / + * `react-dom` against — the host's copies are injected here instead, and the + * `browser-shims/*` modules that stand in for those bare specifiers read + * from this slot. + */ +export interface Slot { + react?: typeof import("react"); + reactDOM?: typeof import("react-dom"); + bus?: Observability; + loadReactLang?: () => Promise; +} + +export const slot: Slot = {}; + +export function requireReact(): typeof import("react") { + if (!slot.react) { + throw new Error( + "[@openuidev/devtools] the widget module loaded before mountOpenUIDevtools() ran — this is a bug in the browser build, not host code.", + ); + } + return slot.react; +} + +export function requireReactDOM(): typeof import("react-dom") { + if (!slot.reactDOM) { + throw new Error( + "[@openuidev/devtools] the widget module loaded before mountOpenUIDevtools() ran — this is a bug in the browser build, not host code.", + ); + } + return slot.reactDOM; +} + +export function requireLoadReactLang(): () => Promise { + if (!slot.loadReactLang) { + throw new Error( + "[@openuidev/devtools] the widget module loaded before mountOpenUIDevtools() ran — this is a bug in the browser build, not host code.", + ); + } + return slot.loadReactLang; +} diff --git a/packages/devtools/src/browser.ts b/packages/devtools/src/browser.ts new file mode 100644 index 000000000..d9ae891b6 --- /dev/null +++ b/packages/devtools/src/browser.ts @@ -0,0 +1,74 @@ +/** + * Entry point for the browser build (dist/devtools.browser.js). Fetched at + * runtime by the thin npm wrapper / react-lang bootstrap — not imported as a + * normal package entry. + * + * This file is the only thing evaluated eagerly. `./OpenUIDevtoolsWidget` (and + * everything it pulls in — theme.ts's module-scope `createContext`, + * DebugUI's `class ... extends Component`) is reached only through the + * dynamic import() below, so the slot below is always filled — real React, + * not the browser-shims/* placeholders — before any of that runs. + */ +import { slot } from "./browser-shims/slots"; +import type { OpenUIDevtoolsWidgetProps } from "./types"; + +const BUS_KEY = Symbol.for("openui.observability"); + +/** @internal Called by the thin package wrapper after it loads this file. */ +export interface MountOptions { + React: typeof import("react"); + ReactDOM: typeof import("react-dom"); + ReactDOMClient: typeof import("react-dom/client"); + /** Closed over the host's module graph — the browser build never imports + * "@openuidev/react-lang" itself. Required so Debug can parse/render. */ + loadReactLang: () => Promise; + /** Widget props from `` (theme, position, …). */ + props?: OpenUIDevtoolsWidgetProps; + /** Explicit bus, mainly for tests. Defaults to the Symbol.for singleton + * that "@openuidev/observability" itself publishes to. */ + bus?: import("@openuidev/observability").Observability; +} + +/** @internal Mounts the widget; call the returned function to unmount. */ +export function mountOpenUIDevtools(opts: MountOptions): () => void { + const bus = opts.bus ?? (globalThis as { [BUS_KEY]?: unknown })[BUS_KEY]; + if (!bus) { + console.warn( + "[@openuidev/devtools] no observability bus found on globalThis — import " + + '"@openuidev/observability" before mounting. Widget not mounted.', + ); + return () => {}; + } + + slot.react = opts.React; + slot.reactDOM = opts.ReactDOM; + slot.bus = bus as import("@openuidev/observability").Observability; + slot.loadReactLang = opts.loadReactLang; + + let cancelled = false; + let unmount = () => { + cancelled = true; + }; + + import("./OpenUIDevtoolsWidget").then(({ OpenUIDevtoolsWidget }) => { + if (cancelled) return; + + const attach = () => { + if (cancelled || !document.body) return; + const host = document.createElement("div"); + host.setAttribute("data-openui-devtools-root", ""); + document.body.appendChild(host); + const root = opts.ReactDOMClient.createRoot(host); + root.render(opts.React.createElement(OpenUIDevtoolsWidget, opts.props ?? {})); + unmount = () => { + root.unmount(); + host.remove(); + }; + }; + + if (document.body) attach(); + else document.addEventListener("DOMContentLoaded", attach, { once: true }); + }); + + return () => unmount(); +} diff --git a/packages/devtools/src/cdn.ts b/packages/devtools/src/cdn.ts new file mode 100644 index 000000000..7e2b4b13b --- /dev/null +++ b/packages/devtools/src/cdn.ts @@ -0,0 +1,70 @@ +import type { OpenUIDevtoolsProps, OpenUIDevtoolsWidgetProps } from "./types"; + +/** jsDelivr URL for the browser bundle. Omit `cdnMajor` → `@latest`. */ +export function browserBundleUrl(cdnMajor?: number): string { + const tag = cdnMajor === undefined ? "latest" : String(cdnMajor); + return `https://cdn.jsdelivr.net/npm/@openuidev/devtools@${tag}/dist/devtools.browser.js`; +} + +export type MountFromCdnOptions = OpenUIDevtoolsProps; + +type BrowserModule = { + mountOpenUIDevtools: (opts: { + React: typeof import("react"); + ReactDOM: typeof import("react-dom"); + ReactDOMClient: typeof import("react-dom/client"); + loadReactLang: () => Promise; + props?: OpenUIDevtoolsWidgetProps; + }) => () => void; +}; + +/** + * Fetches the CDN browser bundle and mounts it with the host's React / + * ReactDOM / react-lang. Used by `` and by react-lang's + * auto-mount (with `cdnMajor: 0`). + */ +export function mountOpenUIDevtoolsFromCdn(opts: MountFromCdnOptions = {}): () => void { + const { cdnMajor, enabled, ...widgetProps } = opts; + const isEnabled = + enabled ?? (typeof process === "undefined" || process.env["NODE_ENV"] !== "production"); + + if (!isEnabled || typeof document === "undefined") { + return () => {}; + } + + let cancelled = false; + let unmount = () => { + cancelled = true; + }; + + const url = browserBundleUrl(cdnMajor); + + Promise.all([ + import(/* webpackIgnore: true */ /* @vite-ignore */ url) as Promise, + import("react"), + import("react-dom"), + import("react-dom/client"), + ]) + .then(([devtools, react, reactDom, reactDomClient]) => { + if (cancelled) return; + const attach = () => { + if (cancelled) return; + unmount = devtools.mountOpenUIDevtools({ + React: react, + ReactDOM: reactDom, + ReactDOMClient: reactDomClient, + // Closed over this module's graph so the bundler resolves it — + // the CDN file never imports "@openuidev/react-lang" itself. + loadReactLang: () => import("@openuidev/react-lang"), + props: { ...widgetProps, enabled }, + }); + }; + if (document.body) attach(); + else document.addEventListener("DOMContentLoaded", attach, { once: true }); + }) + .catch(() => { + // Never let a CDN / mount failure break the host app. + }); + + return () => unmount(); +} diff --git a/packages/devtools/src/index.ts b/packages/devtools/src/index.ts index e935246f0..d6d043406 100644 --- a/packages/devtools/src/index.ts +++ b/packages/devtools/src/index.ts @@ -1,4 +1,4 @@ "use client"; export { OpenUIDevtools, type OpenUIDevtoolsProps } from "./OpenUIDevtools"; -export type { ColorMode } from "./theme"; +export type { ColorMode, DevtoolsPosition } from "./types"; diff --git a/packages/devtools/src/types.ts b/packages/devtools/src/types.ts new file mode 100644 index 000000000..153bfec6c --- /dev/null +++ b/packages/devtools/src/types.ts @@ -0,0 +1,43 @@ +import type { ColorMode } from "./theme"; + +export type { ColorMode }; + +export type DevtoolsPosition = "top-left" | "top-right" | "bottom-left" | "bottom-right"; + +/** + * Public props for ``. The thin package entry fetches the + * CDN widget and forwards these into it (except `cdnMajor`, which only + * selects which package tag to load). + */ +export interface OpenUIDevtoolsProps { + /** Force the widget on/off. Defaults to on outside production builds. */ + enabled?: boolean; + /** Corner for the floating toggle button. Defaults to "bottom-right". */ + position?: DevtoolsPosition; + /** How many events to keep; oldest are dropped first. */ + maxEvents?: number; + /** Initial state of the drawer's "errors only" display filter: only + * error/warning events (default) or every event. */ + errorsOnly?: boolean; + /** Initial state of the drawer's "auto-open on error" checkbox. Defaults to true. */ + autoOpenOnError?: boolean; + /** + * Widget UI theme. If passed, it wins over the stored Settings choice + * and is written to config. Otherwise the stored theme is used, then light. + * Never auto-detected from the host page or the OS. + */ + theme?: ColorMode; + /** + * CDN major to fetch (`@0`, `@1`, …). Omit to use `@latest`. + * react-lang's auto-mount passes `0` so the protocol major stays pinned. + */ + cdnMajor?: number; + /** + * @internal Set by react-lang's auto-mount. Auto-mounted instances yield to + * any manually rendered so host-provided props win. + */ + __autoMounted?: boolean; +} + +/** Props the CDN widget itself understands (everything except the CDN tag). */ +export type OpenUIDevtoolsWidgetProps = Omit; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 19bf98fb4..32cd4863e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1761,6 +1761,9 @@ importers: '@types/react-dom': specifier: 'catalog:' version: 19.2.3(@types/react@19.2.17) + esbuild: + specifier: ^0.25.12 + version: 0.25.12 jsdom: specifier: 'catalog:' version: 26.1.0 From 2560127cbf483421131ada3c98749a6f5c971d44 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Mon, 24 Aug 2026 15:56:12 +0530 Subject: [PATCH 2/8] Auto-mount OpenUI Devtools from CDN via thin package helper. Use mountOpenUIDevtoolsFromCdn with cdnMajor 0 so react-lang pulls the major-pinned browser bundle without embedding the widget UI. --- packages/react-lang/package.json | 2 +- packages/react-lang/src/devtoolsBootstrap.ts | 25 +++++++------------- 2 files changed, 10 insertions(+), 17 deletions(-) diff --git a/packages/react-lang/package.json b/packages/react-lang/package.json index 58a410073..3d459ca2f 100644 --- a/packages/react-lang/package.json +++ b/packages/react-lang/package.json @@ -1,6 +1,6 @@ { "name": "@openuidev/react-lang", - "version": "0.2.14", + "version": "0.2.15", "description": "Define component libraries, generate LLM system prompts, and render streaming OpenUI Lang output in React — the core runtime for OpenUI generative UI", "license": "MIT", "type": "module", diff --git a/packages/react-lang/src/devtoolsBootstrap.ts b/packages/react-lang/src/devtoolsBootstrap.ts index 4654e036c..cd979e4db 100644 --- a/packages/react-lang/src/devtoolsBootstrap.ts +++ b/packages/react-lang/src/devtoolsBootstrap.ts @@ -3,9 +3,9 @@ * * This module runs as a top-level side effect when the web entry is loaded in * a browser. In production builds the whole block is dead-code-eliminated by - * the consumer's bundler (the NODE_ENV condition folds to false), so neither - * `@openuidev/devtools` nor `react-dom/client` enters the production graph. - * The React Native entry (index.native.ts) never imports this module. + * the consumer's bundler (the NODE_ENV condition folds to false), so + * `@openuidev/devtools` never enters the production graph. The React Native + * entry (index.native.ts) never imports this module. * * This module is inlined into the web entry (dist/index.*), which is listed * in package.json's `sideEffects` so bundlers preserve the side effect while @@ -21,19 +21,12 @@ if (process.env.NODE_ENV === "development" && typeof document !== "undefined") { // (ESM/CJS dual build, multiple package versions). if (!flags[AUTO_MOUNT_FLAG]) { flags[AUTO_MOUNT_FLAG] = true; - Promise.all([import("@openuidev/devtools"), import("react"), import("react-dom/client")]) - .then(([devtools, react, reactDomClient]) => { - const mount = () => { - const host = document.createElement("div"); - host.setAttribute("data-openui-devtools-root", ""); - document.body.appendChild(host); - reactDomClient - .createRoot(host) - .render(react.createElement(devtools.OpenUIDevtools, { __autoMounted: true })); - }; - // Module evaluation can happen before exists (script in ). - if (document.body) mount(); - else document.addEventListener("DOMContentLoaded", mount, { once: true }); + // Thin helper in @openuidev/devtools fetches the CDN browser build and + // injects this app's React / ReactDOM / react-lang. Pin major 0 so a + // protocol-breaking release ships as @1 instead of taking every app down. + void import("@openuidev/devtools") + .then(({ mountOpenUIDevtoolsFromCdn }) => { + mountOpenUIDevtoolsFromCdn({ cdnMajor: 0, __autoMounted: true }); }) .catch(() => { // Never let a devtools loading failure break the host app. From d14b61c252a55ee5c2e195f288540e8dfba33d67 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Mon, 24 Aug 2026 16:03:58 +0530 Subject: [PATCH 3/8] chore: bump @openuidev/devtools to 0.1.0 --- packages/devtools/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/devtools/package.json b/packages/devtools/package.json index b2baa8838..496137e0d 100644 --- a/packages/devtools/package.json +++ b/packages/devtools/package.json @@ -1,6 +1,6 @@ { "name": "@openuidev/devtools", - "version": "0.0.9", + "version": "0.1.0", "description": "Development-only UI widget for OpenUI apps: surfaces errors captured by @openuidev/observability", "license": "MIT", "type": "module", From 33f5adecd1e077f04165edbf8a99c644c4146092 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Mon, 24 Aug 2026 16:09:03 +0530 Subject: [PATCH 4/8] Auto-mount via instead of the CDN helper. --- packages/react-lang/src/devtoolsBootstrap.ts | 29 ++++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/packages/react-lang/src/devtoolsBootstrap.ts b/packages/react-lang/src/devtoolsBootstrap.ts index cd979e4db..e3875bff3 100644 --- a/packages/react-lang/src/devtoolsBootstrap.ts +++ b/packages/react-lang/src/devtoolsBootstrap.ts @@ -21,12 +21,29 @@ if (process.env.NODE_ENV === "development" && typeof document !== "undefined") { // (ESM/CJS dual build, multiple package versions). if (!flags[AUTO_MOUNT_FLAG]) { flags[AUTO_MOUNT_FLAG] = true; - // Thin helper in @openuidev/devtools fetches the CDN browser build and - // injects this app's React / ReactDOM / react-lang. Pin major 0 so a - // protocol-breaking release ships as @1 instead of taking every app down. - void import("@openuidev/devtools") - .then(({ mountOpenUIDevtoolsFromCdn }) => { - mountOpenUIDevtoolsFromCdn({ cdnMajor: 0, __autoMounted: true }); + // Render — same public entry apps use manually. The + // thin component fetches the CDN browser build and injects this app's + // React / ReactDOM / react-lang. Pin major 0 so a protocol-breaking + // release ships as @1 instead of taking every app down. + void Promise.all([ + import("@openuidev/devtools"), + import("react"), + import("react-dom/client"), + ]) + .then(([{ OpenUIDevtools }, React, ReactDOMClient]) => { + const attach = () => { + const host = document.createElement("div"); + host.setAttribute("data-openui-devtools-auto-mount", ""); + document.body.appendChild(host); + ReactDOMClient.createRoot(host).render( + React.createElement(OpenUIDevtools, { + cdnMajor: 0, + __autoMounted: true, + }), + ); + }; + if (document.body) attach(); + else document.addEventListener("DOMContentLoaded", attach, { once: true }); }) .catch(() => { // Never let a devtools loading failure break the host app. From 6aa0a0f3c309169bd16bce4d1d16458f6f81d520 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Mon, 24 Aug 2026 16:25:33 +0530 Subject: [PATCH 5/8] fix: resolve react-lang --- .../devtools/src/browser-shims/react-lang.ts | 3 +- packages/react-lang/src/devtoolsBootstrap.ts | 63 ++++++++++--------- 2 files changed, 36 insertions(+), 30 deletions(-) diff --git a/packages/devtools/src/browser-shims/react-lang.ts b/packages/devtools/src/browser-shims/react-lang.ts index 99dd43d17..04a76a708 100644 --- a/packages/devtools/src/browser-shims/react-lang.ts +++ b/packages/devtools/src/browser-shims/react-lang.ts @@ -1,6 +1,7 @@ import { requireLoadReactLang } from "./slots"; -const mod = (await requireLoadReactLang()) as +// `requireLoadReactLang()` returns the host-injected loader — call it, then await. +const mod = (await requireLoadReactLang()()) as | { Renderer?: unknown; createParser?: unknown; diff --git a/packages/react-lang/src/devtoolsBootstrap.ts b/packages/react-lang/src/devtoolsBootstrap.ts index e3875bff3..2b30bcd0d 100644 --- a/packages/react-lang/src/devtoolsBootstrap.ts +++ b/packages/react-lang/src/devtoolsBootstrap.ts @@ -14,40 +14,45 @@ // Strict equality with "development" (rather than !== "production") keeps the // widget out of test runners like Jest with jsdom, where NODE_ENV is "test". -if (process.env.NODE_ENV === "development" && typeof document !== "undefined") { +// Defer the `document` check so bundlers (Next/Turbopack) cannot DCE the whole +// block by treating `typeof document` as `"undefined"` at compile time. +if (process.env.NODE_ENV === "development") { const AUTO_MOUNT_FLAG = Symbol.for("openui.devtools.autoMount"); const flags = globalThis as { [key: symbol]: boolean | undefined }; - // Once per document, even if multiple copies of this module load + // Once per JS realm, even if multiple copies of this module load // (ESM/CJS dual build, multiple package versions). if (!flags[AUTO_MOUNT_FLAG]) { flags[AUTO_MOUNT_FLAG] = true; - // Render — same public entry apps use manually. The - // thin component fetches the CDN browser build and injects this app's - // React / ReactDOM / react-lang. Pin major 0 so a protocol-breaking - // release ships as @1 instead of taking every app down. - void Promise.all([ - import("@openuidev/devtools"), - import("react"), - import("react-dom/client"), - ]) - .then(([{ OpenUIDevtools }, React, ReactDOMClient]) => { - const attach = () => { - const host = document.createElement("div"); - host.setAttribute("data-openui-devtools-auto-mount", ""); - document.body.appendChild(host); - ReactDOMClient.createRoot(host).render( - React.createElement(OpenUIDevtools, { - cdnMajor: 0, - __autoMounted: true, - }), - ); - }; - if (document.body) attach(); - else document.addEventListener("DOMContentLoaded", attach, { once: true }); - }) - .catch(() => { - // Never let a devtools loading failure break the host app. - }); + void Promise.resolve().then(() => { + if (typeof document === "undefined") return; + // Render — same public entry apps use manually. The + // thin component fetches the CDN browser build and injects this app's + // React / ReactDOM / react-lang. Pin major 0 so a protocol-breaking + // release ships as @1 instead of taking every app down. + void Promise.all([ + import("@openuidev/devtools"), + import("react"), + import("react-dom/client"), + ]) + .then(([{ OpenUIDevtools }, React, ReactDOMClient]) => { + const attach = () => { + const host = document.createElement("div"); + host.setAttribute("data-openui-devtools-auto-mount", ""); + document.body.appendChild(host); + ReactDOMClient.createRoot(host).render( + React.createElement(OpenUIDevtools, { + cdnMajor: 0, + __autoMounted: true, + }), + ); + }; + if (document.body) attach(); + else document.addEventListener("DOMContentLoaded", attach, { once: true }); + }) + .catch(() => { + // Never let a devtools loading failure break the host app. + }); + }); } } From 12e36c4a09686a583eaf839c9fb726c6e52a9176 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Mon, 24 Aug 2026 16:36:07 +0530 Subject: [PATCH 6/8] fix: fromat --- packages/react-lang/src/devtoolsBootstrap.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/react-lang/src/devtoolsBootstrap.ts b/packages/react-lang/src/devtoolsBootstrap.ts index 2b30bcd0d..c6bb5f944 100644 --- a/packages/react-lang/src/devtoolsBootstrap.ts +++ b/packages/react-lang/src/devtoolsBootstrap.ts @@ -29,11 +29,7 @@ if (process.env.NODE_ENV === "development") { // thin component fetches the CDN browser build and injects this app's // React / ReactDOM / react-lang. Pin major 0 so a protocol-breaking // release ships as @1 instead of taking every app down. - void Promise.all([ - import("@openuidev/devtools"), - import("react"), - import("react-dom/client"), - ]) + void Promise.all([import("@openuidev/devtools"), import("react"), import("react-dom/client")]) .then(([{ OpenUIDevtools }, React, ReactDOMClient]) => { const attach = () => { const host = document.createElement("div"); From 7a762274c81d4a95cd784e9d04907f78f0488861 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Mon, 24 Aug 2026 20:11:33 +0530 Subject: [PATCH 7/8] fix: update --- docs/content/docs/api-reference/devtools.mdx | 10 ++--- packages/devtools/README.md | 8 ++-- packages/devtools/src/OpenUIDevtools.tsx | 10 ++--- .../devtools/src/browser-shims/jsx-runtime.ts | 2 - packages/devtools/src/cdn.ts | 39 +++++++++++++++---- packages/devtools/src/types.ts | 12 +++--- packages/react-lang/src/devtoolsBootstrap.ts | 2 +- 7 files changed, 52 insertions(+), 31 deletions(-) diff --git a/docs/content/docs/api-reference/devtools.mdx b/docs/content/docs/api-reference/devtools.mdx index 5ca022dda..7a30612d3 100644 --- a/docs/content/docs/api-reference/devtools.mdx +++ b/docs/content/docs/api-reference/devtools.mdx @@ -15,15 +15,11 @@ Development-only UI widget for OpenUI apps. Renders a floating button that opens ### CDN (via `react-lang`) -If your app uses [`@openuidev/react-lang`](/docs/api-reference/react-lang), the widget shows up automatically in development. `react-lang` calls this package's thin helper, which fetches the browser build from jsDelivr (pinned to major `0`). - -#### CSP - -`script-src` must allow `cdn.jsdelivr.net` for the fetch to succeed. If it's blocked, the widget silently fails to appear – the rest of the app is unaffected. +If your app uses [`@openuidev/react-lang`](/docs/api-reference/react-lang), the widget shows up automatically in development. ### Package Installation -Install the package and render `` when you want custom props. The component is still a thin CDN wrapper — your props (`theme`, `position`, …) are forwarded into the fetched widget. A manually mounted instance always wins over the auto-mount: +Install the package and render `` when you want custom props. A manually mounted instance always wins over the auto-mount: ```bash tab="pnpm" tab-group="pkg" pnpm add -D @openuidev/devtools @@ -70,4 +66,4 @@ function App() { | `errorsOnly` | `false` | Initial state of the "errors only" display filter. | | `autoOpenOnError` | `true` | Initial state of the drawer's "auto-open on error" checkbox. | | `theme` | `"light"` | `"light"` or `"dark"` theme for the drawer. | -| `cdnMajor` | `@latest` | Pin the CDN package major (`0` → `@0`). Omit for `@latest`. | +| `version` | `@latest` | CDN pin: `"0"` (major), `"0.1"` (minor), or `"0.1.0"` (exact). | diff --git a/packages/devtools/README.md b/packages/devtools/README.md index 12b336289..7925edcc5 100644 --- a/packages/devtools/README.md +++ b/packages/devtools/README.md @@ -4,9 +4,9 @@ Development-only UI widget for OpenUI apps. Renders a floating button that opens ## Usage -If your app uses `@openuidev/react-lang`, the widget shows up automatically. `react-lang` loads this package's thin helper and fetches the browser build from a CDN (pinned to major `0`). +If your app uses `@openuidev/react-lang`, the widget shows up automatically. -You can also mount it yourself — the npm package is a thin wrapper that still fetches the CDN widget and injects your app's React / ReactDOM / react-lang. All props are forwarded into that widget: +You can also mount it yourself. All props are forwarded into that widget: ```tsx import { OpenUIDevtools } from "@openuidev/devtools"; @@ -23,7 +23,7 @@ function App() { | Prop | Default | Notes | | --- | --- | --- | -| `cdnMajor` | `@latest` | Pass `0` (etc.) to pin `https://cdn.jsdelivr.net/npm/@openuidev/devtools@0/...` | +| `version` | `@latest` | Pin CDN tag: `"0"` (major), `"0.1"` (minor), or `"0.1.0"` (exact). | | `theme`, `position`, `maxEvents`, `errorsOnly`, `autoOpenOnError`, `enabled` | see below | Forwarded into the CDN widget as-is | A manually mounted instance always wins over the auto-mount — only one instance ever renders. @@ -50,4 +50,4 @@ Debug renders through the host's own `Renderer`. Its previews stay off the event | `errorsOnly` | `true` | Capture only error/warning events, or all. | | `autoOpenOnError` | `true` | Initial state of the "auto-open on error" setting. | | `theme` | `"light"` | Initial widget chrome theme: `"light"` or `"dark"` (Settings overrides). | -| `cdnMajor` | `@latest` | Pin the CDN package major (`0` → `@0`). Omit for `@latest`. | +| `version` | `@latest` | CDN pin: `"0"` / `"0.1"` / `"0.1.0"`. Omit for `@latest`. | diff --git a/packages/devtools/src/OpenUIDevtools.tsx b/packages/devtools/src/OpenUIDevtools.tsx index 7c97b7263..1ea4fc6e2 100644 --- a/packages/devtools/src/OpenUIDevtools.tsx +++ b/packages/devtools/src/OpenUIDevtools.tsx @@ -12,8 +12,8 @@ export type { ColorMode, DevtoolsPosition, OpenUIDevtoolsProps } from "./types"; * The npm package is a thin host wrapper: in development it fetches the CDN * browser build and mounts it with this app's React, ReactDOM, and react-lang. * All props (`theme`, `position`, `maxEvents`, …) are forwarded into that - * widget. Pass `cdnMajor` to pin a protocol major (`0` → `@0`); omit it for - * `@latest`. + * widget. Pass `version` to pin the CDN tag (`"0"`, `"0.1"`, `"0.1.0"`); + * omit it for `@latest`. * * Renders nothing itself — the widget attaches to `document.body`. */ @@ -25,7 +25,7 @@ export function OpenUIDevtools(props: OpenUIDevtoolsProps) { errorsOnly, autoOpenOnError, theme, - cdnMajor, + version, __autoMounted, } = props; @@ -37,10 +37,10 @@ export function OpenUIDevtools(props: OpenUIDevtoolsProps) { errorsOnly, autoOpenOnError, theme, - cdnMajor, + version, __autoMounted, }); - }, [enabled, position, maxEvents, errorsOnly, autoOpenOnError, theme, cdnMajor, __autoMounted]); + }, [enabled, position, maxEvents, errorsOnly, autoOpenOnError, theme, version, __autoMounted]); return null; } diff --git a/packages/devtools/src/browser-shims/jsx-runtime.ts b/packages/devtools/src/browser-shims/jsx-runtime.ts index ae45ee19a..fa54997c4 100644 --- a/packages/devtools/src/browser-shims/jsx-runtime.ts +++ b/packages/devtools/src/browser-shims/jsx-runtime.ts @@ -8,5 +8,3 @@ export const Fragment = react.Fragment; export function jsx(type: unknown, props: Record, key?: string) { return react.createElement(type as never, key === undefined ? props : { ...props, key }); } - -export const jsxs = jsx; diff --git a/packages/devtools/src/cdn.ts b/packages/devtools/src/cdn.ts index 7e2b4b13b..3395c4b0b 100644 --- a/packages/devtools/src/cdn.ts +++ b/packages/devtools/src/cdn.ts @@ -1,8 +1,26 @@ import type { OpenUIDevtoolsProps, OpenUIDevtoolsWidgetProps } from "./types"; -/** jsDelivr URL for the browser bundle. Omit `cdnMajor` → `@latest`. */ -export function browserBundleUrl(cdnMajor?: number): string { - const tag = cdnMajor === undefined ? "latest" : String(cdnMajor); +/** Major (`0`), minor (`0.1`), or exact (`0.1.0`). */ +const VERSION_RE = /^\d+(\.\d+){0,2}$/; + +/** + * Normalize a CDN version pin. Returns `"latest"` when omitted/empty. + * Returns `null` when the string is present but not a major/minor/exact pin. + */ +export function normalizeCdnVersion(version?: string): string | null { + const trimmed = version?.trim(); + if (!trimmed) return "latest"; + return VERSION_RE.test(trimmed) ? trimmed : null; +} + +/** + * jsDelivr URL for the browser bundle. + * Omit `version` → `@latest`. Otherwise major (`"0"`), minor (`"0.1"`), + * or exact (`"0.1.0"`) npm tags. + */ +export function browserBundleUrl(version?: string): string | null { + const tag = normalizeCdnVersion(version); + if (tag === null) return null; return `https://cdn.jsdelivr.net/npm/@openuidev/devtools@${tag}/dist/devtools.browser.js`; } @@ -21,10 +39,10 @@ type BrowserModule = { /** * Fetches the CDN browser bundle and mounts it with the host's React / * ReactDOM / react-lang. Used by `` and by react-lang's - * auto-mount (with `cdnMajor: 0`). + * auto-mount (with `version: "0"`). */ export function mountOpenUIDevtoolsFromCdn(opts: MountFromCdnOptions = {}): () => void { - const { cdnMajor, enabled, ...widgetProps } = opts; + const { version, enabled, ...widgetProps } = opts; const isEnabled = enabled ?? (typeof process === "undefined" || process.env["NODE_ENV"] !== "production"); @@ -32,13 +50,20 @@ export function mountOpenUIDevtoolsFromCdn(opts: MountFromCdnOptions = {}): () = return () => {}; } + const url = browserBundleUrl(version); + if (url === null) { + console.warn( + `[@openuidev/devtools] invalid version "${version?.trim()}" — use a major ("0"), ` + + `minor ("0.1"), or exact ("0.1.0") pin. Widget not mounted.`, + ); + return () => {}; + } + let cancelled = false; let unmount = () => { cancelled = true; }; - const url = browserBundleUrl(cdnMajor); - Promise.all([ import(/* webpackIgnore: true */ /* @vite-ignore */ url) as Promise, import("react"), diff --git a/packages/devtools/src/types.ts b/packages/devtools/src/types.ts index 153bfec6c..8b30d3aa5 100644 --- a/packages/devtools/src/types.ts +++ b/packages/devtools/src/types.ts @@ -6,7 +6,7 @@ export type DevtoolsPosition = "top-left" | "top-right" | "bottom-left" | "botto /** * Public props for ``. The thin package entry fetches the - * CDN widget and forwards these into it (except `cdnMajor`, which only + * CDN widget and forwards these into it (except `version`, which only * selects which package tag to load). */ export interface OpenUIDevtoolsProps { @@ -28,10 +28,12 @@ export interface OpenUIDevtoolsProps { */ theme?: ColorMode; /** - * CDN major to fetch (`@0`, `@1`, …). Omit to use `@latest`. - * react-lang's auto-mount passes `0` so the protocol major stays pinned. + * CDN package version to fetch. Omit for `@latest`. + * Must be a major (`"0"`), minor (`"0.1"`), or exact (`"0.1.0"`) pin — + * other strings are rejected and the widget does not mount. + * react-lang's auto-mount passes `"0"` so the protocol major stays pinned. */ - cdnMajor?: number; + version?: string; /** * @internal Set by react-lang's auto-mount. Auto-mounted instances yield to * any manually rendered so host-provided props win. @@ -40,4 +42,4 @@ export interface OpenUIDevtoolsProps { } /** Props the CDN widget itself understands (everything except the CDN tag). */ -export type OpenUIDevtoolsWidgetProps = Omit; +export type OpenUIDevtoolsWidgetProps = Omit; diff --git a/packages/react-lang/src/devtoolsBootstrap.ts b/packages/react-lang/src/devtoolsBootstrap.ts index c6bb5f944..b6c0a963f 100644 --- a/packages/react-lang/src/devtoolsBootstrap.ts +++ b/packages/react-lang/src/devtoolsBootstrap.ts @@ -37,7 +37,7 @@ if (process.env.NODE_ENV === "development") { document.body.appendChild(host); ReactDOMClient.createRoot(host).render( React.createElement(OpenUIDevtools, { - cdnMajor: 0, + version: "0", __autoMounted: true, }), ); From 63024976528e5eacc87a4813b722448448d17bf1 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Mon, 24 Aug 2026 20:19:59 +0530 Subject: [PATCH 8/8] fix: add jsx runtime --- docs/content/docs/api-reference/index.mdx | 4 ++-- packages/devtools/src/browser-shims/jsx-runtime.ts | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/content/docs/api-reference/index.mdx b/docs/content/docs/api-reference/index.mdx index c53c47cb1..9096c6513 100644 --- a/docs/content/docs/api-reference/index.mdx +++ b/docs/content/docs/api-reference/index.mdx @@ -25,7 +25,7 @@ The OpenUI SDK is split into packages that build on each other: - **`@openuidev/browser-bundle`** — Prebuilt browser bundle for CDN, iframe, and no-build integrations. It packages the renderer, UI library, React, ReactDOM, and styles into script-tag-friendly assets. -- **`@openuidev/devtools`** — Development-only floating widget that surfaces the events captured by `@openuidev/observability`. Auto-mounts from a CDN in `react-lang` apps. +- **`@openuidev/devtools`** — Development-only floating widget that surfaces the events captured by `@openuidev/observability`, with error messages and stack traces. - **`@openuidev/cli`** — Command-line tool for scaffolding new OpenUI chat apps and generating system prompts or JSON schemas from library definitions. @@ -97,7 +97,7 @@ The OpenUI SDK is split into packages that build on each other: CDN, iframe, and no-build browser bundle for the renderer, UI library, React, and styles. - Development-only floating widget. Auto-mounts from a CDN in development for react-lang apps. + Development-only floating widget surfacing captured events with error messages and stack traces. openui create (scaffold a Next.js app) and openui generate (system prompt + library spec from a diff --git a/packages/devtools/src/browser-shims/jsx-runtime.ts b/packages/devtools/src/browser-shims/jsx-runtime.ts index fa54997c4..ae45ee19a 100644 --- a/packages/devtools/src/browser-shims/jsx-runtime.ts +++ b/packages/devtools/src/browser-shims/jsx-runtime.ts @@ -8,3 +8,5 @@ export const Fragment = react.Fragment; export function jsx(type: unknown, props: Record, key?: string) { return react.createElement(type as never, key === undefined ? props : { ...props, key }); } + +export const jsxs = jsx;