diff --git a/libs/ui/CLAUDE.md b/libs/ui/CLAUDE.md index a20aa9c1..eba21bf9 100644 --- a/libs/ui/CLAUDE.md +++ b/libs/ui/CLAUDE.md @@ -4,7 +4,7 @@ `@frontmcp/ui` provides **React components, hooks, and rendering utilities** for building interactive MCP widgets. -This package requires React. For React-free utilities (bundling, build tools, HTML components, platform adapters), use `@frontmcp/uipack`. +This package requires React. For React-free utilities (bundling, build tools, platform adapters, theme), use `@frontmcp/uipack`. **Key Principles:** @@ -17,41 +17,55 @@ This package requires React. For React-free utilities (bundling, build tools, HT ```text libs/ui/src/ +├── bridge/ # MCP bridge runtime and adapters +├── bundler/ # SSR component bundling (re-exports from uipack) +├── components/ # HTML string components (button, card, etc.) +├── layouts/ # Page layout templates ├── react/ # React components and hooks -│ ├── components/ # Button, Card, Alert, Badge, etc. +│ ├── Card.tsx # Card component +│ ├── Button.tsx # Button component +│ ├── Alert.tsx # Alert component +│ ├── Badge.tsx # Badge component │ └── hooks/ # useMcpBridge, useCallTool, useToolInput ├── render/ # React 19 static rendering utilities ├── renderers/ # React renderer for template processing │ ├── react.renderer.ts # SSR renderer (react-dom/server) -│ └── react.adapter.ts # Client-side hydration adapter -├── bundler/ # SSR component bundling +│ ├── react.adapter.ts # Client-side hydration adapter +│ └── mdx.renderer.ts # MDX server-side renderer ├── universal/ # Universal React app shell +├── web-components/ # Custom HTML elements └── index.ts # Main barrel exports ``` ## Package Split -| Package | Purpose | React Required | -| ------------------ | --------------------------------------------------------- | -------------- | -| `@frontmcp/ui` | React components, hooks, SSR | Yes | -| `@frontmcp/uipack` | Bundling, build tools, HTML components, platform adapters | No | +| Package | Purpose | React Required | +| ------------------ | ----------------------------------------------- | -------------- | +| `@frontmcp/ui` | React components, hooks, SSR, HTML components | Yes | +| `@frontmcp/uipack` | Bundling, build tools, platform adapters, theme | No | ### Import Patterns ```typescript // React components and hooks (this package) -import { Button, Card, Alert } from '@frontmcp/ui/react'; -import { useMcpBridge, useCallTool } from '@frontmcp/ui/react/hooks'; +import { Button, Card, Alert, Badge } from '@frontmcp/ui/react'; +import { useMcpBridge, useCallTool, useToolInput } from '@frontmcp/ui/react'; // SSR rendering -import { ReactRenderer, reactRenderer } from '@frontmcp/ui/renderers'; +import { ReactRenderer, reactRenderer, MdxRenderer, mdxRenderer } from '@frontmcp/ui/renderers'; // Universal app shell import { UniversalApp, FrontMCPProvider } from '@frontmcp/ui/universal'; +// HTML string components +import { button, card, alert, badge } from '@frontmcp/ui/components'; + +// MCP bridge +import { FrontMcpBridge, createBridge } from '@frontmcp/ui/bridge'; + // React-free utilities (from @frontmcp/uipack) import { buildToolUI } from '@frontmcp/uipack/build'; -import { button, card } from '@frontmcp/uipack/components'; +import { DEFAULT_THEME } from '@frontmcp/uipack/theme'; import type { AIPlatformType } from '@frontmcp/uipack/adapters'; ``` @@ -60,41 +74,50 @@ import type { AIPlatformType } from '@frontmcp/uipack/adapters'; ### Available Components ```typescript -import { - Button, - Card, - Alert, - Badge, - // ... more components -} from '@frontmcp/ui/react'; +import { Card, Badge, Button, Alert } from '@frontmcp/ui/react'; // Usage - +

Card content

+ +Active + + + Please check your input + ``` ### MCP Bridge Hooks ```typescript -import { useMcpBridge, useCallTool, useToolInput, useToolOutput } from '@frontmcp/ui/react/hooks'; +import { + McpBridgeProvider, + useMcpBridge, + useCallTool, + useToolInput, + useToolOutput, + useTheme, +} from '@frontmcp/ui/react'; function MyWidget() { - const bridge = useMcpBridge(); - const { call, loading, error } = useCallTool(); - const input = useToolInput(); - const output = useToolOutput(); + const input = useToolInput<{ location: string }>(); + const theme = useTheme(); + const [getWeather, { data, loading }] = useCallTool('get_weather'); + return {loading ? 'Loading...' : data?.temperature}; +} + +// Wrap your app with the provider +function App() { return ( -
-

Input: {JSON.stringify(input)}

-

Output: {JSON.stringify(output)}

- -
+ + + ); } ``` @@ -110,6 +133,15 @@ import { ReactRenderer, reactRenderer } from '@frontmcp/ui/renderers'; const html = await reactRenderer.render(MyComponent, context); ``` +### MDX Server Rendering + +```typescript +import { MdxRenderer, mdxRenderer } from '@frontmcp/ui/renderers'; + +// Render MDX to HTML with React components +const html = await mdxRenderer.render('# Hello {output.name}', context); +``` + ### Client-Side Hydration ```typescript @@ -145,6 +177,8 @@ function App() { ## SSR Bundling +The bundler re-exports utilities from `@frontmcp/uipack/bundler`: + ```typescript import { InMemoryBundler, createBundler } from '@frontmcp/ui/bundler'; @@ -178,15 +212,18 @@ const result = await bundler.bundle(componentPath); ## Entry Points -| Path | Purpose | -| -------------------------- | ------------------------------------------ | -| `@frontmcp/ui` | Main exports (React components, renderers) | -| `@frontmcp/ui/react` | React components | -| `@frontmcp/ui/react/hooks` | MCP bridge hooks | -| `@frontmcp/ui/renderers` | ReactRenderer, ReactRendererAdapter | -| `@frontmcp/ui/render` | React 19 static rendering | -| `@frontmcp/ui/universal` | Universal app shell | -| `@frontmcp/ui/bundler` | SSR component bundler | +| Path | Purpose | +| ----------------------------- | ------------------------------------------ | +| `@frontmcp/ui` | Main exports (React components, renderers) | +| `@frontmcp/ui/react` | React components and hooks | +| `@frontmcp/ui/renderers` | ReactRenderer, MdxRenderer, adapters | +| `@frontmcp/ui/render` | React 19 static rendering | +| `@frontmcp/ui/universal` | Universal app shell | +| `@frontmcp/ui/bundler` | SSR component bundler | +| `@frontmcp/ui/bridge` | MCP bridge runtime | +| `@frontmcp/ui/components` | HTML string components | +| `@frontmcp/ui/layouts` | Page layout templates | +| `@frontmcp/ui/web-components` | Custom HTML elements | ## Anti-Patterns to Avoid @@ -198,6 +235,6 @@ const result = await bundler.bundle(componentPath); ## Related Packages -- **@frontmcp/uipack** - React-free bundling, build tools, HTML components +- **@frontmcp/uipack** - React-free bundling, build tools, theme, platform adapters - **@frontmcp/sdk** - Core FrontMCP SDK - **@frontmcp/testing** - E2E testing utilities diff --git a/libs/ui/package.json b/libs/ui/package.json index 0ac4f0bf..7ddd7d93 100644 --- a/libs/ui/package.json +++ b/libs/ui/package.json @@ -57,7 +57,6 @@ "dependencies": { "@mdx-js/mdx": "^3.1.1", "@swc/core": "^1.5.0", - "enclave-vm": "^1.0.3", "esbuild": "^0.27.1", "zod": "^4.0.0" }, diff --git a/libs/ui/project.json b/libs/ui/project.json index 1759f1ee..5469d673 100644 --- a/libs/ui/project.json +++ b/libs/ui/project.json @@ -25,13 +25,11 @@ "libs/ui/src/bundler/index.ts", "libs/ui/src/components/index.ts", "libs/ui/src/layouts/index.ts", - "libs/ui/src/pages/index.ts", "libs/ui/src/react/index.ts", "libs/ui/src/render/index.ts", "libs/ui/src/renderers/index.ts", "libs/ui/src/universal/index.ts", - "libs/ui/src/web-components/index.ts", - "libs/ui/src/widgets/index.ts" + "libs/ui/src/web-components/index.ts" ], "esbuildOptions": { "outExtension": { ".js": ".js" }, @@ -63,13 +61,11 @@ "libs/ui/src/bundler/index.ts", "libs/ui/src/components/index.ts", "libs/ui/src/layouts/index.ts", - "libs/ui/src/pages/index.ts", "libs/ui/src/react/index.ts", "libs/ui/src/render/index.ts", "libs/ui/src/renderers/index.ts", "libs/ui/src/universal/index.ts", - "libs/ui/src/web-components/index.ts", - "libs/ui/src/widgets/index.ts" + "libs/ui/src/web-components/index.ts" ], "esbuildOptions": { "outExtension": { ".js": ".mjs" }, diff --git a/libs/ui/src/bridge/__tests__/iife-generator.test.ts b/libs/ui/src/bridge/__tests__/iife-generator.test.ts index 6a219ded..8ad9250a 100644 --- a/libs/ui/src/bridge/__tests__/iife-generator.test.ts +++ b/libs/ui/src/bridge/__tests__/iife-generator.test.ts @@ -2,12 +2,7 @@ * IIFE Generator Tests */ -import { - generateBridgeIIFE, - generatePlatformBundle, - UNIVERSAL_BRIDGE_SCRIPT, - BRIDGE_SCRIPT_TAGS, -} from '../runtime/iife-generator'; +import { generateBridgeIIFE, generatePlatformBundle, UNIVERSAL_BRIDGE_SCRIPT, BRIDGE_SCRIPT_TAGS } from '../runtime'; describe('IIFE Generator', () => { describe('generateBridgeIIFE', () => { diff --git a/libs/ui/src/bridge/index.ts b/libs/ui/src/bridge/index.ts index 78ba8119..5c56a2ba 100644 --- a/libs/ui/src/bridge/index.ts +++ b/libs/ui/src/bridge/index.ts @@ -102,11 +102,11 @@ export { GenericAdapter, createGenericAdapter } from './adapters/generic.adapter export { registerBuiltInAdapters } from './adapters'; -// Runtime +// Runtime - Re-exported from @frontmcp/uipack (single source of truth) export { generateBridgeIIFE, generatePlatformBundle, UNIVERSAL_BRIDGE_SCRIPT, BRIDGE_SCRIPT_TAGS, type IIFEGeneratorOptions, -} from './runtime/iife-generator'; +} from './runtime'; diff --git a/libs/ui/src/bundler/__tests__/cache.test.ts b/libs/ui/src/bundler/__tests__/cache.test.ts deleted file mode 100644 index a59362cb..00000000 --- a/libs/ui/src/bundler/__tests__/cache.test.ts +++ /dev/null @@ -1,491 +0,0 @@ -/** - * Bundler Cache Tests - * - * Tests for the LRU cache, hash functions, and cache key generation. - */ - -import { BundlerCache, hashContent, createCacheKey, type CacheStats } from '../cache'; -import type { BundleResult } from '../types'; - -// ============================================ -// Test Fixtures -// ============================================ - -function createMockResult(overrides: Partial = {}): BundleResult { - return { - code: 'console.log("test");', - size: 20, - cached: false, - hash: 'abc123', - ...overrides, - }; -} - -// ============================================ -// BundlerCache Tests -// ============================================ - -describe('BundlerCache', () => { - let cache: BundlerCache; - - beforeEach(() => { - cache = new BundlerCache({ maxSize: 10, ttl: 5000 }); - }); - - describe('Constructor', () => { - it('should create cache with default options', () => { - const defaultCache = new BundlerCache(); - expect(defaultCache.size).toBe(0); - }); - - it('should create cache with custom options', () => { - const customCache = new BundlerCache({ maxSize: 50, ttl: 10000 }); - expect(customCache.size).toBe(0); - }); - }); - - describe('set and get', () => { - it('should store and retrieve entries', () => { - const result = createMockResult(); - cache.set('key1', result); - - const retrieved = cache.get('key1'); - expect(retrieved).toEqual(result); - }); - - it('should return undefined for missing keys', () => { - expect(cache.get('nonexistent')).toBeUndefined(); - }); - - it('should update access count on get', () => { - const result = createMockResult(); - cache.set('key1', result); - - cache.get('key1'); - cache.get('key1'); - cache.get('key1'); - - const stats = cache.getStats(); - expect(stats.hits).toBe(3); - }); - - it('should track cache misses', () => { - cache.get('missing1'); - cache.get('missing2'); - - const stats = cache.getStats(); - expect(stats.misses).toBe(2); - }); - }); - - describe('has', () => { - it('should return true for existing keys', () => { - cache.set('key1', createMockResult()); - expect(cache.has('key1')).toBe(true); - }); - - it('should return false for missing keys', () => { - expect(cache.has('missing')).toBe(false); - }); - }); - - describe('delete', () => { - it('should delete existing entries', () => { - cache.set('key1', createMockResult()); - expect(cache.has('key1')).toBe(true); - - const deleted = cache.delete('key1'); - expect(deleted).toBe(true); - expect(cache.has('key1')).toBe(false); - }); - - it('should return false for missing keys', () => { - expect(cache.delete('missing')).toBe(false); - }); - }); - - describe('clear', () => { - it('should remove all entries', () => { - cache.set('key1', createMockResult()); - cache.set('key2', createMockResult()); - cache.set('key3', createMockResult()); - - expect(cache.size).toBe(3); - - cache.clear(); - expect(cache.size).toBe(0); - }); - - it('should reset statistics', () => { - cache.set('key1', createMockResult()); - cache.get('key1'); - cache.get('missing'); - - cache.clear(); - - const stats = cache.getStats(); - expect(stats.hits).toBe(0); - expect(stats.misses).toBe(0); - }); - }); - - describe('LRU Eviction', () => { - it('should evict oldest entries when at capacity', () => { - const smallCache = new BundlerCache({ maxSize: 3, ttl: 60000 }); - - smallCache.set('key1', createMockResult({ code: 'code1' })); - smallCache.set('key2', createMockResult({ code: 'code2' })); - smallCache.set('key3', createMockResult({ code: 'code3' })); - smallCache.set('key4', createMockResult({ code: 'code4' })); - - expect(smallCache.size).toBe(3); - expect(smallCache.has('key1')).toBe(false); // Evicted - expect(smallCache.has('key2')).toBe(true); - expect(smallCache.has('key3')).toBe(true); - expect(smallCache.has('key4')).toBe(true); - }); - - it('should track eviction count', () => { - const smallCache = new BundlerCache({ maxSize: 2, ttl: 60000 }); - - smallCache.set('key1', createMockResult()); - smallCache.set('key2', createMockResult()); - smallCache.set('key3', createMockResult()); - smallCache.set('key4', createMockResult()); - - const stats = smallCache.getStats(); - expect(stats.evictions).toBe(2); - }); - - it('should refresh LRU order on access', () => { - const smallCache = new BundlerCache({ maxSize: 3, ttl: 60000 }); - - smallCache.set('key1', createMockResult({ code: 'code1' })); - smallCache.set('key2', createMockResult({ code: 'code2' })); - smallCache.set('key3', createMockResult({ code: 'code3' })); - - // Access key1, making it most recently used - smallCache.get('key1'); - - // Add key4, should evict key2 (now oldest) - smallCache.set('key4', createMockResult({ code: 'code4' })); - - expect(smallCache.has('key1')).toBe(true); - expect(smallCache.has('key2')).toBe(false); // Evicted - expect(smallCache.has('key3')).toBe(true); - expect(smallCache.has('key4')).toBe(true); - }); - }); - - describe('TTL Expiration', () => { - it('should expire entries after TTL', async () => { - const shortTtlCache = new BundlerCache({ maxSize: 10, ttl: 50 }); - - shortTtlCache.set('key1', createMockResult()); - expect(shortTtlCache.has('key1')).toBe(true); - - await new Promise((resolve) => setTimeout(resolve, 60)); - - expect(shortTtlCache.has('key1')).toBe(false); - expect(shortTtlCache.get('key1')).toBeUndefined(); - }); - - it('should count expired entries as misses', async () => { - const shortTtlCache = new BundlerCache({ maxSize: 10, ttl: 50 }); - - shortTtlCache.set('key1', createMockResult()); - shortTtlCache.get('key1'); // Hit - - await new Promise((resolve) => setTimeout(resolve, 60)); - - shortTtlCache.get('key1'); // Miss (expired) - - const stats = shortTtlCache.getStats(); - expect(stats.hits).toBe(1); - expect(stats.misses).toBe(1); - }); - - it('should track TTL evictions', async () => { - const shortTtlCache = new BundlerCache({ maxSize: 10, ttl: 50 }); - - shortTtlCache.set('key1', createMockResult()); - - await new Promise((resolve) => setTimeout(resolve, 60)); - - shortTtlCache.get('key1'); // Triggers expiration - - const stats = shortTtlCache.getStats(); - expect(stats.evictions).toBe(1); - }); - }); - - describe('cleanup', () => { - it('should remove expired entries', async () => { - const shortTtlCache = new BundlerCache({ maxSize: 10, ttl: 50 }); - - shortTtlCache.set('key1', createMockResult()); - shortTtlCache.set('key2', createMockResult()); - - await new Promise((resolve) => setTimeout(resolve, 60)); - - const removed = shortTtlCache.cleanup(); - expect(removed).toBe(2); - expect(shortTtlCache.size).toBe(0); - }); - - it('should not remove non-expired entries', async () => { - const cache = new BundlerCache({ maxSize: 10, ttl: 10000 }); - - cache.set('key1', createMockResult()); - cache.set('key2', createMockResult()); - - const removed = cache.cleanup(); - expect(removed).toBe(0); - expect(cache.size).toBe(2); - }); - }); - - describe('keys', () => { - it('should return all cache keys', () => { - cache.set('key1', createMockResult()); - cache.set('key2', createMockResult()); - cache.set('key3', createMockResult()); - - const keys = cache.keys(); - expect(keys).toHaveLength(3); - expect(keys).toContain('key1'); - expect(keys).toContain('key2'); - expect(keys).toContain('key3'); - }); - - it('should return empty array for empty cache', () => { - expect(cache.keys()).toEqual([]); - }); - }); - - describe('getStats', () => { - it('should return correct statistics', () => { - cache.set('key1', createMockResult({ size: 100 })); - cache.set('key2', createMockResult({ size: 200, map: 'sourcemap' })); - - cache.get('key1'); // Hit - cache.get('key2'); // Hit - cache.get('missing'); // Miss - - const stats = cache.getStats(); - - expect(stats.size).toBe(2); - expect(stats.hits).toBe(2); - expect(stats.misses).toBe(1); - expect(stats.hitRate).toBeCloseTo(2 / 3); - expect(stats.memoryUsage).toBe(100 + 200 + 'sourcemap'.length); - }); - - it('should return zero hit rate for empty cache', () => { - const stats = cache.getStats(); - expect(stats.hitRate).toBe(0); - }); - }); - - describe('size property', () => { - it('should return correct size', () => { - expect(cache.size).toBe(0); - - cache.set('key1', createMockResult()); - expect(cache.size).toBe(1); - - cache.set('key2', createMockResult()); - expect(cache.size).toBe(2); - - cache.delete('key1'); - expect(cache.size).toBe(1); - }); - }); -}); - -// ============================================ -// hashContent Tests -// ============================================ - -describe('hashContent', () => { - it('should return consistent hash for same content', () => { - const content = 'const x = 1;'; - const hash1 = hashContent(content); - const hash2 = hashContent(content); - - expect(hash1).toBe(hash2); - }); - - it('should return different hash for different content', () => { - const hash1 = hashContent('const x = 1;'); - const hash2 = hashContent('const x = 2;'); - - expect(hash1).not.toBe(hash2); - }); - - it('should return 8-character hex string', () => { - const hash = hashContent('test'); - - expect(hash).toMatch(/^[0-9a-f]{8}$/); - }); - - it('should handle empty string', () => { - const hash = hashContent(''); - expect(hash).toBeDefined(); - expect(hash).toMatch(/^[0-9a-f]{8}$/); - }); - - it('should handle unicode characters', () => { - const hash = hashContent('日本語テスト'); - expect(hash).toBeDefined(); - expect(hash).toMatch(/^[0-9a-f]{8}$/); - }); - - it('should handle very long content', () => { - const content = 'x'.repeat(100000); - const hash = hashContent(content); - expect(hash).toBeDefined(); - expect(hash).toMatch(/^[0-9a-f]{8}$/); - }); - - it('should be sensitive to small changes', () => { - const hash1 = hashContent('abcdefghijklmnop'); - const hash2 = hashContent('abcdefghijklmnoq'); - - expect(hash1).not.toBe(hash2); - }); -}); - -// ============================================ -// createCacheKey Tests -// ============================================ - -describe('createCacheKey', () => { - it('should create consistent key for same inputs', () => { - const options = { sourceType: 'jsx', format: 'esm', minify: true }; - const key1 = createCacheKey('const x = 1;', options); - const key2 = createCacheKey('const x = 1;', options); - - expect(key1).toBe(key2); - }); - - it('should create different key for different source', () => { - const options = { sourceType: 'jsx' }; - const key1 = createCacheKey('const x = 1;', options); - const key2 = createCacheKey('const x = 2;', options); - - expect(key1).not.toBe(key2); - }); - - it('should create different key for different options', () => { - const source = 'const x = 1;'; - const key1 = createCacheKey(source, { minify: true }); - const key2 = createCacheKey(source, { minify: false }); - - expect(key1).not.toBe(key2); - }); - - it('should include source type in key', () => { - const source = 'const x = 1;'; - const key1 = createCacheKey(source, { sourceType: 'jsx' }); - const key2 = createCacheKey(source, { sourceType: 'tsx' }); - - expect(key1).not.toBe(key2); - }); - - it('should include format in key', () => { - const source = 'const x = 1;'; - const key1 = createCacheKey(source, { format: 'esm' }); - const key2 = createCacheKey(source, { format: 'cjs' }); - - expect(key1).not.toBe(key2); - }); - - it('should include target in key', () => { - const source = 'const x = 1;'; - const key1 = createCacheKey(source, { target: 'es2020' }); - const key2 = createCacheKey(source, { target: 'es2022' }); - - expect(key1).not.toBe(key2); - }); - - it('should include externals in key', () => { - const source = 'const x = 1;'; - const key1 = createCacheKey(source, { externals: ['react'] }); - const key2 = createCacheKey(source, { externals: ['vue'] }); - - expect(key1).not.toBe(key2); - }); - - it('should sort externals for consistent key', () => { - const source = 'const x = 1;'; - const key1 = createCacheKey(source, { externals: ['react', 'lodash'] }); - const key2 = createCacheKey(source, { externals: ['lodash', 'react'] }); - - expect(key1).toBe(key2); - }); - - it('should return key in format sourceHash-optionsHash', () => { - const key = createCacheKey('test', { sourceType: 'jsx' }); - - expect(key).toMatch(/^[0-9a-f]{8}-[0-9a-f]{8}$/); - }); - - it('should handle empty options', () => { - const key = createCacheKey('test', {}); - expect(key).toBeDefined(); - expect(key).toMatch(/^[0-9a-f]{8}-[0-9a-f]{8}$/); - }); - - it('should handle undefined options', () => { - const key = createCacheKey('test', { - sourceType: undefined, - format: undefined, - minify: undefined, - }); - expect(key).toBeDefined(); - }); -}); - -// ============================================ -// Edge Cases -// ============================================ - -describe('Edge Cases', () => { - it('should handle concurrent access', async () => { - const cache = new BundlerCache({ maxSize: 100, ttl: 60000 }); - - // Simulate concurrent writes - const promises = Array.from({ length: 100 }, (_, i) => - Promise.resolve().then(() => { - cache.set(`key${i}`, createMockResult({ code: `code${i}` })); - }), - ); - - await Promise.all(promises); - - expect(cache.size).toBe(100); - }); - - it('should handle rapid get/set cycles', () => { - const cache = new BundlerCache({ maxSize: 5, ttl: 60000 }); - - for (let i = 0; i < 1000; i++) { - cache.set(`key${i % 10}`, createMockResult({ code: `code${i}` })); - cache.get(`key${i % 10}`); - } - - expect(cache.size).toBeLessThanOrEqual(5); - }); - - it('should handle special characters in cache keys', () => { - const cache = new BundlerCache({ maxSize: 10, ttl: 60000 }); - - const specialKeys = ['key:with:colons', 'key/with/slashes', 'key\\with\\backslashes', 'key with spaces']; - - specialKeys.forEach((key) => { - cache.set(key, createMockResult()); - expect(cache.has(key)).toBe(true); - }); - }); -}); diff --git a/libs/ui/src/bundler/browser-components.ts b/libs/ui/src/bundler/browser-components.ts index b02e810d..e4fa9a7a 100644 --- a/libs/ui/src/bundler/browser-components.ts +++ b/libs/ui/src/bundler/browser-components.ts @@ -11,7 +11,6 @@ */ import * as path from 'path'; -import * as fs from 'fs'; // ============================================ // Cache diff --git a/libs/ui/src/bundler/bundler.ts b/libs/ui/src/bundler/bundler.ts index 105f2ffc..c2cb946c 100644 --- a/libs/ui/src/bundler/bundler.ts +++ b/libs/ui/src/bundler/bundler.ts @@ -41,9 +41,17 @@ import { } from './types'; import { buildUIMeta, type AIPlatformType } from '@frontmcp/uipack/adapters'; import { DEFAULT_THEME, buildThemeCss, type ThemeConfig } from '@frontmcp/uipack/theme'; -import { BundlerCache, createCacheKey, hashContent } from './cache'; -import { validateSource, validateSize, mergePolicy, throwOnViolations } from './sandbox/policy'; -import { executeDefault, ExecutionError } from './sandbox/executor'; +import { + BundlerCache, + createCacheKey, + hashContent, + validateSource, + validateSize, + mergePolicy, + throwOnViolations, + executeDefault, + ExecutionError, +} from '@frontmcp/uipack/bundler'; import { escapeHtml } from '@frontmcp/uipack/utils'; import type { ContentType } from '../universal/types'; import { detectContentType as detectUniversalContentType } from '../universal/types'; @@ -2119,5 +2127,4 @@ export function createBundler(options?: BundlerOptions): InMemoryBundler { } // Re-export errors for convenience -export { SecurityError } from './sandbox/policy'; -export { ExecutionError } from './sandbox/executor'; +export { SecurityError, ExecutionError } from '@frontmcp/uipack/bundler'; diff --git a/libs/ui/src/bundler/cache.ts b/libs/ui/src/bundler/cache.ts deleted file mode 100644 index f6940f02..00000000 --- a/libs/ui/src/bundler/cache.ts +++ /dev/null @@ -1,335 +0,0 @@ -/** - * Bundler Cache - * - * LRU cache implementation for bundled results. - * Provides content-addressable caching with TTL expiration. - * - * @packageDocumentation - */ - -import type { BundleResult, CacheEntry } from './types'; - -/** - * Cache configuration options. - */ -export interface CacheOptions { - /** - * Maximum number of entries in the cache. - * @default 100 - */ - maxSize: number; - - /** - * Time-to-live for cache entries in milliseconds. - * @default 300000 (5 minutes) - */ - ttl: number; -} - -/** - * Cache statistics. - */ -export interface CacheStats { - /** - * Number of entries in the cache. - */ - size: number; - - /** - * Number of cache hits. - */ - hits: number; - - /** - * Number of cache misses. - */ - misses: number; - - /** - * Hit rate (0-1). - */ - hitRate: number; - - /** - * Number of expired entries removed. - */ - evictions: number; - - /** - * Total memory used by cache (approximate). - */ - memoryUsage: number; -} - -/** - * LRU cache for bundled results. - * - * Features: - * - Content-addressable by hash - * - TTL-based expiration - * - LRU eviction when at capacity - * - Statistics tracking - * - * @example - * ```typescript - * const cache = new BundlerCache({ maxSize: 100, ttl: 300000 }); - * - * // Store a result - * cache.set('abc123', bundleResult); - * - * // Retrieve a result - * const cached = cache.get('abc123'); - * if (cached) { - * console.log('Cache hit!', cached); - * } - * - * // Get statistics - * console.log(cache.getStats()); - * ``` - */ -export class BundlerCache { - private readonly cache = new Map(); - private readonly options: CacheOptions; - private stats = { - hits: 0, - misses: 0, - evictions: 0, - }; - - constructor(options: Partial = {}) { - this.options = { - maxSize: options.maxSize ?? 100, - ttl: options.ttl ?? 300000, - }; - } - - /** - * Get a cached bundle result. - * - * @param key - Cache key (typically content hash) - * @returns Cached result or undefined if not found/expired - */ - get(key: string): BundleResult | undefined { - const entry = this.cache.get(key); - - if (!entry) { - this.stats.misses++; - return undefined; - } - - // Check TTL - if (this.isExpired(entry)) { - this.cache.delete(key); - this.stats.misses++; - this.stats.evictions++; - return undefined; - } - - // Update access tracking - entry.lastAccessedAt = Date.now(); - entry.accessCount++; - this.stats.hits++; - - // Move to end (most recently used) by re-inserting - this.cache.delete(key); - this.cache.set(key, entry); - - return entry.result; - } - - /** - * Store a bundle result in the cache. - * - * @param key - Cache key (typically content hash) - * @param result - Bundle result to cache - */ - set(key: string, result: BundleResult): void { - // Enforce capacity limit - while (this.cache.size >= this.options.maxSize) { - this.evictOldest(); - } - - const now = Date.now(); - const entry: CacheEntry = { - result, - createdAt: now, - lastAccessedAt: now, - accessCount: 1, - }; - - this.cache.set(key, entry); - } - - /** - * Check if a key exists in the cache (and is not expired). - * - * @param key - Cache key to check - * @returns true if key exists and is not expired - */ - has(key: string): boolean { - const entry = this.cache.get(key); - if (!entry) return false; - if (this.isExpired(entry)) { - this.cache.delete(key); - this.stats.evictions++; - return false; - } - return true; - } - - /** - * Delete a specific entry from the cache. - * - * @param key - Cache key to delete - * @returns true if the key was found and deleted - */ - delete(key: string): boolean { - return this.cache.delete(key); - } - - /** - * Clear all entries from the cache. - */ - clear(): void { - this.cache.clear(); - this.stats = { - hits: 0, - misses: 0, - evictions: 0, - }; - } - - /** - * Get cache statistics. - * - * @returns Current cache statistics - */ - getStats(): CacheStats { - const total = this.stats.hits + this.stats.misses; - const hitRate = total > 0 ? this.stats.hits / total : 0; - - let memoryUsage = 0; - for (const entry of this.cache.values()) { - memoryUsage += entry.result.size; - if (entry.result.map) { - memoryUsage += entry.result.map.length; - } - } - - return { - size: this.cache.size, - hits: this.stats.hits, - misses: this.stats.misses, - hitRate, - evictions: this.stats.evictions, - memoryUsage, - }; - } - - /** - * Remove expired entries from the cache. - * - * @returns Number of entries removed - */ - cleanup(): number { - let removed = 0; - - for (const [key, entry] of this.cache.entries()) { - if (this.isExpired(entry)) { - this.cache.delete(key); - removed++; - } - } - - this.stats.evictions += removed; - return removed; - } - - /** - * Get all cache keys. - * - * @returns Array of cache keys - */ - keys(): string[] { - return Array.from(this.cache.keys()); - } - - /** - * Get the number of entries in the cache. - */ - get size(): number { - return this.cache.size; - } - - /** - * Check if an entry is expired. - */ - private isExpired(entry: CacheEntry): boolean { - return Date.now() - entry.createdAt > this.options.ttl; - } - - /** - * Evict the oldest (least recently used) entry. - */ - private evictOldest(): void { - // Map maintains insertion order, so first key is oldest - const oldestKey = this.cache.keys().next().value; - if (oldestKey !== undefined) { - this.cache.delete(oldestKey); - this.stats.evictions++; - } - } -} - -/** - * Create a hash from source content. - * - * Uses a simple but fast hashing algorithm suitable for cache keys. - * - * @param content - Content to hash - * @returns Hash string - */ -export function hashContent(content: string): string { - // FNV-1a hash - fast and good distribution - let hash = 2166136261; - for (let i = 0; i < content.length; i++) { - hash ^= content.charCodeAt(i); - hash = Math.imul(hash, 16777619); - } - - // Convert to hex string - return (hash >>> 0).toString(16).padStart(8, '0'); -} - -/** - * Create a hash from bundle options. - * - * Used to differentiate bundles of the same source with different options. - * - * @param source - Source content - * @param options - Bundle options that affect output - * @returns Combined hash string - */ -export function createCacheKey( - source: string, - options: { - sourceType?: string; - format?: string; - minify?: boolean; - externals?: string[]; - target?: string; - }, -): string { - const sourceHash = hashContent(source); - const optionsHash = hashContent( - JSON.stringify({ - sourceType: options.sourceType, - format: options.format, - minify: options.minify, - externals: options.externals?.sort(), - target: options.target, - }), - ); - - return `${sourceHash}-${optionsHash}`; -} diff --git a/libs/ui/src/bundler/file-cache/component-builder.ts b/libs/ui/src/bundler/file-cache/component-builder.ts deleted file mode 100644 index f7140fa0..00000000 --- a/libs/ui/src/bundler/file-cache/component-builder.ts +++ /dev/null @@ -1,509 +0,0 @@ -/** - * Component Builder - * - * Builds file-based UI components with caching and CDN dependency resolution. - * Handles the complete build pipeline from source to cached manifest. - * - * @packageDocumentation - */ - -import { readFile } from 'fs/promises'; -import { existsSync } from 'fs'; -import { resolve, extname } from 'path'; -import { randomUUID } from 'crypto'; - -import type { - ComponentBuildManifest, - FileBundleOptions, - CDNDependency, - CDNPlatformType, - ResolvedDependency, -} from '@frontmcp/uipack/dependency'; -import type { BuildCacheStorage } from './storage/interface'; -import { calculateComponentHash } from './hash-calculator'; -import { DependencyResolver, createImportMap, generateDependencyHTML } from '@frontmcp/uipack/dependency'; - -// ============================================ -// Builder Options -// ============================================ - -/** - * Options for building a component. - */ -export interface ComponentBuildOptions { - /** - * Entry file path. - */ - entryPath: string; - - /** - * Tool name for the component. - */ - toolName: string; - - /** - * Packages to load from CDN. - */ - externals?: string[]; - - /** - * Explicit CDN dependency overrides. - */ - dependencies?: Record; - - /** - * Bundle options. - */ - bundleOptions?: FileBundleOptions; - - /** - * Target platform for CDN selection. - * @default 'unknown' - */ - platform?: CDNPlatformType; - - /** - * Whether to skip cache lookup. - * @default false - */ - skipCache?: boolean; - - /** - * Whether to perform SSR. - * @default false - */ - ssr?: boolean; - - /** - * SSR context data. - */ - ssrContext?: Record; - - /** - * Custom code execution function for SSR. - * Allows different strategies for Node.js vs browser environments. - * If not provided, uses `new Function()` which works in both environments. - * - * @example - * ```typescript - * // Custom execution with vm module - * import { createContext, runInContext } from 'vm'; - * - * const options = { - * executeCode: (code, exports, module, React) => { - * const context = createContext({ exports, module, React }); - * runInContext(code, context); - * } - * }; - * ``` - */ - executeCode?: ( - code: string, - exports: Record, - module: { exports: Record }, - React: unknown, - ) => void; -} - -/** - * Result of a build operation. - */ -export interface ComponentBuildResult { - /** - * The build manifest. - */ - manifest: ComponentBuildManifest; - - /** - * Whether the result came from cache. - */ - cached: boolean; - - /** - * Build time in milliseconds. - */ - buildTimeMs: number; -} - -// ============================================ -// Builder Class -// ============================================ - -/** - * Component builder for file-based UI templates. - * - * Handles the complete build pipeline: - * 1. Check cache for existing build - * 2. Parse entry file for imports - * 3. Resolve external dependencies to CDN URLs - * 4. Bundle the component with esbuild - * 5. Generate import map for CDN dependencies - * 6. Store result in cache - * - * @example - * ```typescript - * const storage = new FilesystemStorage({ cacheDir: '.cache' }); - * await storage.initialize(); - * - * const builder = new ComponentBuilder(storage); - * - * const result = await builder.build({ - * entryPath: './widgets/chart.tsx', - * toolName: 'chart_display', - * externals: ['chart.js', 'react'], - * platform: 'claude', - * }); - * - * console.log(result.manifest.outputs.code); - * console.log(result.manifest.importMap); - * ``` - */ -export class ComponentBuilder { - private readonly storage: BuildCacheStorage; - private esbuild: typeof import('esbuild') | null = null; - - constructor(storage: BuildCacheStorage) { - this.storage = storage; - } - - /** - * Build a component from a file path. - */ - async build(options: ComponentBuildOptions): Promise { - const startTime = performance.now(); - const { - entryPath, - toolName, - externals = [], - dependencies = {}, - bundleOptions = {}, - platform = 'unknown', - skipCache = false, - ssr = false, - ssrContext = {}, - executeCode, - } = options; - - // Resolve absolute path - const absoluteEntryPath = resolve(entryPath); - - if (!existsSync(absoluteEntryPath)) { - throw new Error(`Entry file not found: ${absoluteEntryPath}`); - } - - // Calculate content hash - const hashResult = await calculateComponentHash({ - entryPath: absoluteEntryPath, - externals, - dependencies, - bundleOptions, - }); - - // Check cache - if (!skipCache) { - const cached = await this.storage.get(hashResult.hash); - if (cached) { - return { - manifest: cached, - cached: true, - buildTimeMs: performance.now() - startTime, - }; - } - } - - // Read entry file - const source = await readFile(absoluteEntryPath, 'utf8'); - - // Resolve external dependencies - const resolver = new DependencyResolver({ platform }); - const resolvedDeps: ResolvedDependency[] = []; - - for (const pkg of externals) { - try { - const override = dependencies[pkg]; - const resolved = resolver.resolve(pkg, override); - if (resolved) { - resolvedDeps.push(resolved); - } - // If resolved is null, package will be bundled instead of loaded from CDN - } catch (error) { - // Log warning but continue - package will be bundled instead - console.warn(`Failed to resolve external "${pkg}": ${error}`); - } - } - - // Add peer dependencies - const allExternals = new Set(externals); - for (const dep of resolvedDeps) { - const entry = resolver.getRegistry()[dep.packageName]; - if (entry?.providers) { - const providerConfig = Object.values(entry.providers)[0]; - if (providerConfig?.peerDependencies) { - for (const peer of providerConfig.peerDependencies) { - if (!allExternals.has(peer)) { - allExternals.add(peer); - try { - const peerOverride = dependencies[peer]; - const resolved = resolver.resolve(peer, peerOverride); - if (resolved) { - resolvedDeps.push(resolved); - } - } catch { - // Ignore - peer may not be needed - } - } - } - } - } - } - - // Generate import map - const importMap = createImportMap(resolvedDeps); - - // Bundle the component - const bundleResult = await this.bundleComponent({ - source, - entryPath: absoluteEntryPath, - externals: Array.from(allExternals), - bundleOptions, - }); - - // Optionally perform SSR - let ssrHtml: string | undefined; - if (ssr) { - ssrHtml = await this.renderSSR(bundleResult.code, ssrContext, resolvedDeps, executeCode); - } - - // Create manifest - const manifest: ComponentBuildManifest = { - version: '1.0', - buildId: randomUUID(), - toolName, - entryPath: absoluteEntryPath, - contentHash: hashResult.hash, - dependencies: resolvedDeps, - outputs: { - code: bundleResult.code, - sourceMap: bundleResult.map, - ssrHtml, - }, - importMap, - metadata: { - createdAt: new Date().toISOString(), - buildTimeMs: performance.now() - startTime, - totalSize: Buffer.byteLength(bundleResult.code, 'utf8'), - bundlerVersion: bundleResult.bundlerVersion, - }, - }; - - // Store in cache - await this.storage.set(hashResult.hash, manifest); - - return { - manifest, - cached: false, - buildTimeMs: performance.now() - startTime, - }; - } - - /** - * Build multiple components. - */ - async buildMany(options: ComponentBuildOptions[]): Promise { - return Promise.all(options.map((opt) => this.build(opt))); - } - - /** - * Check if a component needs rebuilding. - */ - async needsRebuild( - options: Pick, - ): Promise { - const absoluteEntryPath = resolve(options.entryPath); - - const hashResult = await calculateComponentHash({ - entryPath: absoluteEntryPath, - externals: options.externals, - dependencies: options.dependencies, - bundleOptions: options.bundleOptions, - }); - - const cached = await this.storage.has(hashResult.hash); - return !cached; - } - - /** - * Get a cached build if it exists. - */ - async getCached( - options: Pick, - ): Promise { - const absoluteEntryPath = resolve(options.entryPath); - - const hashResult = await calculateComponentHash({ - entryPath: absoluteEntryPath, - externals: options.externals, - dependencies: options.dependencies, - bundleOptions: options.bundleOptions, - }); - - return this.storage.get(hashResult.hash); - } - - /** - * Invalidate a cached build. - */ - async invalidate(contentHash: string): Promise { - return this.storage.delete(contentHash); - } - - /** - * Generate complete HTML for a built component. - */ - generateHTML(manifest: ComponentBuildManifest, minify = false): string { - const parts: string[] = []; - - // Add dependency loading HTML - const dependencyHtml = generateDependencyHTML(manifest.dependencies, { minify }); - parts.push(dependencyHtml); - - // Add component code - parts.push(``); - - return parts.join(minify ? '' : '\n'); - } - - /** - * Bundle a component using esbuild. - */ - private async bundleComponent(options: { - source: string; - entryPath: string; - externals: string[]; - bundleOptions: FileBundleOptions; - }): Promise<{ code: string; map?: string; bundlerVersion?: string }> { - const { source, entryPath, externals, bundleOptions } = options; - - // Lazy load esbuild - if (!this.esbuild) { - try { - this.esbuild = await import('esbuild'); - } catch { - throw new Error('esbuild is required for component building. Install with: npm install esbuild'); - } - } - - const ext = extname(entryPath).toLowerCase(); - const loader = ext === '.tsx' ? 'tsx' : ext === '.ts' ? 'ts' : ext === '.jsx' ? 'jsx' : 'js'; - - try { - const result = await this.esbuild.transform(source, { - loader, - format: 'esm', - minify: bundleOptions.minify ?? process.env['NODE_ENV'] === 'production', - sourcemap: bundleOptions.sourceMaps ? 'inline' : false, - target: bundleOptions.target ?? 'es2020', - treeShaking: bundleOptions.treeShake ?? true, - jsx: 'automatic', - jsxImportSource: bundleOptions.jsxImportSource ?? 'react', - // Mark externals for later import map resolution - banner: externals.length > 0 ? `/* externals: ${externals.join(', ')} */` : undefined, - }); - - return { - code: result.code, - map: result.map || undefined, - bundlerVersion: this.esbuild.version, - }; - } catch (error) { - throw new Error(`Bundle failed for ${entryPath}: ${error}`); - } - } - - /** - * Perform server-side rendering. - */ - private async renderSSR( - code: string, - context: Record, - dependencies: ResolvedDependency[], - executeCode?: ( - code: string, - exports: Record, - module: { exports: Record }, - React: unknown, - ) => void, - ): Promise { - // SSR requires React - const hasReact = dependencies.some((d) => d.packageName === 'react'); - if (!hasReact) { - console.warn('SSR requires React as an external dependency'); - return undefined; - } - - try { - // Dynamic import React for SSR - const React = await import('react'); - const ReactDOMServer = await import('react-dom/server'); - - // Create a sandboxed execution context - const exports: Record = {}; - const module = { exports }; - - // Execute the bundled code using custom executor or default new Function() - // Note: new Function() works in both Node.js and browser environments. - // For stricter sandboxing in Node.js, provide a custom executeCode using the vm module. - if (executeCode) { - executeCode(code, exports, module, React); - } else { - const fn = new Function('exports', 'module', 'React', code); - fn(exports, module, React); - } - - // Get the default export - const Component = (module.exports as { default?: unknown }).default || module.exports; - - if (typeof Component !== 'function') { - console.warn('SSR: No default component export found'); - return undefined; - } - - // Render to string - const element = React.createElement(Component as React.ComponentType, context); - return ReactDOMServer.renderToString(element); - } catch (error) { - console.warn(`SSR failed: ${error}`); - return undefined; - } - } -} - -// ============================================ -// Factory Functions -// ============================================ - -/** - * Create a component builder with filesystem storage. - */ -export async function createFilesystemBuilder(cacheDir = '.frontmcp-cache/builds'): Promise { - const { FilesystemStorage } = await import('./storage/filesystem.js'); - const storage = new FilesystemStorage({ cacheDir }); - await storage.initialize(); - return new ComponentBuilder(storage); -} - -/** - * Create a component builder with Redis storage. - */ -export async function createRedisBuilder( - redisClient: import('./storage/redis.js').RedisClient, - keyPrefix = 'frontmcp:ui:build:', -): Promise { - const { RedisStorage } = await import('./storage/redis.js'); - const storage = new RedisStorage({ - client: redisClient, - keyPrefix, - }); - await storage.initialize(); - return new ComponentBuilder(storage); -} diff --git a/libs/ui/src/bundler/file-cache/hash-calculator.ts b/libs/ui/src/bundler/file-cache/hash-calculator.ts deleted file mode 100644 index 35b6581b..00000000 --- a/libs/ui/src/bundler/file-cache/hash-calculator.ts +++ /dev/null @@ -1,398 +0,0 @@ -/** - * Hash Calculator - * - * SHA-256 based hash calculation for cache keys. - * Combines file contents, dependencies, and build options - * to create deterministic cache keys for incremental builds. - * - * @packageDocumentation - */ - -import { createHash } from 'crypto'; -import { readFile } from 'fs/promises'; -import { existsSync } from 'fs'; -import { join, dirname, resolve } from 'path'; - -import type { FileBundleOptions, CDNDependency } from '@frontmcp/uipack/dependency'; - -// ============================================ -// Hash Functions -// ============================================ - -/** - * Calculate SHA-256 hash of a string. - * - * @param content - Content to hash - * @returns Hex-encoded SHA-256 hash - */ -export function sha256(content: string): string { - return createHash('sha256').update(content, 'utf8').digest('hex'); -} - -/** - * Calculate SHA-256 hash of a buffer. - * - * @param buffer - Buffer to hash - * @returns Hex-encoded SHA-256 hash - */ -export function sha256Buffer(buffer: Buffer): string { - return createHash('sha256').update(buffer).digest('hex'); -} - -/** - * Calculate hash of a file's contents. - * - * @param filePath - Path to the file - * @returns SHA-256 hash or undefined if file doesn't exist - */ -export async function hashFile(filePath: string): Promise { - try { - const content = await readFile(filePath); - return sha256Buffer(content); - } catch { - return undefined; - } -} - -/** - * Calculate combined hash of multiple files. - * - * @param filePaths - Paths to files - * @returns Combined SHA-256 hash - */ -export async function hashFiles(filePaths: string[]): Promise { - const hashes: string[] = []; - - for (const filePath of filePaths.sort()) { - const hash = await hashFile(filePath); - if (hash) { - hashes.push(`${filePath}:${hash}`); - } - } - - return sha256(hashes.join('\n')); -} - -// ============================================ -// Component Hash Calculation -// ============================================ - -/** - * Options for calculating a component hash. - */ -export interface ComponentHashOptions { - /** - * Entry file path. - */ - entryPath: string; - - /** - * Base directory for resolving relative imports. - * @default dirname(entryPath) - */ - baseDir?: string; - - /** - * External packages (excluded from hash). - */ - externals?: string[]; - - /** - * Explicit CDN dependencies. - */ - dependencies?: Record; - - /** - * Bundle options. - */ - bundleOptions?: FileBundleOptions; - - /** - * Maximum depth for following imports. - * @default 10 - */ - maxDepth?: number; -} - -/** - * Result of component hash calculation. - */ -export interface ComponentHashResult { - /** - * Combined SHA-256 hash. - */ - hash: string; - - /** - * Entry file hash. - */ - entryHash: string; - - /** - * All files included in the hash. - */ - files: string[]; - - /** - * Individual file hashes. - */ - fileHashes: Record; - - /** - * Hash of build options. - */ - optionsHash: string; - - /** - * Hash of external dependencies configuration. - */ - dependenciesHash: string; -} - -/** - * Calculate a deterministic hash for a file-based component. - * - * The hash includes: - * - Entry file content - * - All local dependencies (relative imports) - * - Bundle options - * - External dependency configurations - * - * External npm packages are NOT included in the hash since they're - * loaded from CDN and versioned separately. - * - * @param options - Hash calculation options - * @returns Hash result with details - * - * @example - * ```typescript - * const result = await calculateComponentHash({ - * entryPath: './src/widgets/chart.tsx', - * externals: ['chart.js', 'react'], - * bundleOptions: { minify: true }, - * }); - * - * console.log(result.hash); // '3a7bd...' - * console.log(result.files); // ['./src/widgets/chart.tsx', './src/widgets/utils.ts'] - * ``` - */ -export async function calculateComponentHash(options: ComponentHashOptions): Promise { - const { - entryPath, - baseDir = dirname(entryPath), - externals: _externals = [], - dependencies = {}, - bundleOptions = {}, - maxDepth = 10, - } = options; - - // Resolve absolute entry path - const absoluteEntryPath = resolve(entryPath); - - // Collect all local files (entry + dependencies) - const files = new Set(); - const fileHashes: Record = {}; - - await collectLocalDependencies(absoluteEntryPath, baseDir, files, maxDepth, 0); - - // Calculate hashes for all files - for (const file of files) { - const hash = await hashFile(file); - if (hash) { - fileHashes[file] = hash; - } - } - - // Sort files for deterministic ordering - const sortedFiles = Array.from(files).sort(); - - // Calculate combined file hash - const fileHashContent = sortedFiles.map((f) => `${f}:${fileHashes[f] || 'missing'}`).join('\n'); - const filesHash = sha256(fileHashContent); - - // Calculate options hash - const optionsHash = sha256(JSON.stringify(sortedObject(bundleOptions as Record))); - - // Calculate dependencies hash - const dependenciesHash = sha256(JSON.stringify(sortedObject(dependencies as Record))); - - // Combine all hashes - const combinedHash = sha256([filesHash, optionsHash, dependenciesHash].join(':')); - - return { - hash: combinedHash, - entryHash: fileHashes[absoluteEntryPath] || '', - files: sortedFiles, - fileHashes, - optionsHash, - dependenciesHash, - }; -} - -/** - * Calculate a quick hash for cache lookup (entry file only). - * - * This is faster than full component hash but may miss dependency changes. - * Use for quick cache existence checks, then verify with full hash. - * - * @param entryPath - Entry file path - * @param bundleOptions - Bundle options - * @returns Quick hash string - */ -export async function calculateQuickHash(entryPath: string, bundleOptions?: FileBundleOptions): Promise { - const entryHash = await hashFile(entryPath); - const optionsHash = bundleOptions - ? sha256(JSON.stringify(sortedObject(bundleOptions as Record))) - : ''; - - return sha256(`${entryHash || 'missing'}:${optionsHash}`); -} - -// ============================================ -// Helper Functions -// ============================================ - -/** - * Collect all local dependencies recursively. - */ -async function collectLocalDependencies( - filePath: string, - baseDir: string, - collected: Set, - maxDepth: number, - currentDepth: number, -): Promise { - if (currentDepth >= maxDepth) return; - if (collected.has(filePath)) return; - - if (!existsSync(filePath)) return; - - collected.add(filePath); - - try { - const content = await readFile(filePath, 'utf8'); - const imports = extractImportPaths(content); - - for (const importPath of imports) { - // Skip external packages - if (!importPath.startsWith('.') && !importPath.startsWith('/')) { - continue; - } - - // Resolve the import path - const resolvedPath = resolveImportPath(importPath, dirname(filePath)); - if (resolvedPath && existsSync(resolvedPath)) { - await collectLocalDependencies(resolvedPath, baseDir, collected, maxDepth, currentDepth + 1); - } - } - } catch { - // Ignore unreadable files - } -} - -/** - * Extract import paths from source code. - */ -function extractImportPaths(source: string): string[] { - const paths: string[] = []; - - // Match import ... from '...' - const importRegex = /import\s+(?:[^'"]+\s+from\s+)?['"]([^'"]+)['"]/g; - let match; - while ((match = importRegex.exec(source)) !== null) { - paths.push(match[1]); - } - - // Match export ... from '...' - const exportRegex = /export\s+(?:\*|{[^}]+})\s+from\s+['"]([^'"]+)['"]/g; - while ((match = exportRegex.exec(source)) !== null) { - paths.push(match[1]); - } - - // Match require('...') - const requireRegex = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g; - while ((match = requireRegex.exec(source)) !== null) { - paths.push(match[1]); - } - - // Match dynamic import('...') - const dynamicRegex = /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g; - while ((match = dynamicRegex.exec(source)) !== null) { - paths.push(match[1]); - } - - return [...new Set(paths)]; -} - -/** - * Resolve an import path to an absolute file path. - */ -function resolveImportPath(importPath: string, fromDir: string): string | undefined { - const extensions = ['', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']; - - for (const ext of extensions) { - const fullPath = join(fromDir, importPath + ext); - if (existsSync(fullPath)) { - return fullPath; - } - } - - // Try as directory with index file - for (const ext of extensions) { - const indexPath = join(fromDir, importPath, `index${ext}`); - if (existsSync(indexPath)) { - return indexPath; - } - } - - return undefined; -} - -/** - * Create a sorted copy of an object for deterministic JSON serialization. - */ -function sortedObject(obj: Record): Record { - const sorted: Record = {}; - const keys = Object.keys(obj).sort(); - - for (const key of keys) { - const value = obj[key]; - if (value && typeof value === 'object' && !Array.isArray(value)) { - sorted[key] = sortedObject(value as Record); - } else { - sorted[key] = value; - } - } - - return sorted; -} - -// ============================================ -// Build ID Generation -// ============================================ - -/** - * Generate a unique build ID. - * - * Combines timestamp and random component for uniqueness. - * - * @returns UUID-like build ID - */ -export function generateBuildId(): string { - const timestamp = Date.now().toString(36); - const random = Math.random().toString(36).substring(2, 10); - return `${timestamp}-${random}`; -} - -/** - * Generate a build ID from a content hash. - * - * Creates a shorter, more readable ID while maintaining uniqueness. - * - * @param hash - Content hash - * @returns Shortened build ID - */ -export function buildIdFromHash(hash: string): string { - // Use first 12 characters of hash - return hash.substring(0, 12); -} diff --git a/libs/ui/src/bundler/file-cache/index.ts b/libs/ui/src/bundler/file-cache/index.ts deleted file mode 100644 index 948f81db..00000000 --- a/libs/ui/src/bundler/file-cache/index.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * File-Based Component Caching - * - * SHA-based caching system for file-based UI component builds. - * Supports filesystem (development) and Redis (production) storage. - * - * @packageDocumentation - */ - -// Storage -export { - // Interface - type BuildCacheStorage, - type StorageOptions, - type CacheEntry, - type CacheEntryMetadata, - DEFAULT_STORAGE_OPTIONS, - calculateManifestSize, - // Filesystem - FilesystemStorage, - createFilesystemStorage, - type FilesystemStorageOptions, - // Redis - RedisStorage, - createRedisStorage, - type RedisStorageOptions, - type RedisClient, -} from './storage'; - -// Hash Calculator -export { - sha256, - sha256Buffer, - hashFile, - hashFiles, - calculateComponentHash, - calculateQuickHash, - generateBuildId, - buildIdFromHash, - type ComponentHashOptions, - type ComponentHashResult, -} from './hash-calculator'; - -// Component Builder -export { - ComponentBuilder, - createFilesystemBuilder, - createRedisBuilder, - type ComponentBuildOptions, - type ComponentBuildResult, -} from './component-builder'; diff --git a/libs/ui/src/bundler/file-cache/storage/filesystem.ts b/libs/ui/src/bundler/file-cache/storage/filesystem.ts deleted file mode 100644 index a2892cfa..00000000 --- a/libs/ui/src/bundler/file-cache/storage/filesystem.ts +++ /dev/null @@ -1,494 +0,0 @@ -/** - * Filesystem Build Cache Storage - * - * File-based cache storage for development environments. - * Uses LRU eviction and stores manifests as JSON files. - * - * @packageDocumentation - */ - -import { mkdir, readFile, writeFile, readdir, unlink, rm } from 'fs/promises'; -import { join, dirname } from 'path'; -import { existsSync } from 'fs'; -import { createHash } from 'crypto'; - -import type { ComponentBuildManifest, CacheStats } from '@frontmcp/uipack/dependency'; -import type { BuildCacheStorage, StorageOptions, CacheEntry } from './interface'; -import { DEFAULT_STORAGE_OPTIONS, calculateManifestSize } from './interface'; - -// ============================================ -// Error Classes -// ============================================ - -/** - * Error thrown when cache storage fails to initialize. - */ -export class CacheInitializationError extends Error { - override readonly cause?: unknown; - - constructor(message: string, cause?: unknown) { - super(message); - this.name = 'CacheInitializationError'; - this.cause = cause; - } -} - -/** - * Error thrown when a cache operation fails. - */ -export class CacheOperationError extends Error { - override readonly cause?: unknown; - - constructor(message: string, cause?: unknown) { - super(message); - this.name = 'CacheOperationError'; - this.cause = cause; - } -} - -/** - * Error thrown when storage is accessed before initialization. - */ -export class StorageNotInitializedError extends Error { - constructor() { - super('Storage not initialized. Call initialize() first.'); - this.name = 'StorageNotInitializedError'; - } -} - -/** - * Options specific to filesystem storage. - */ -export interface FilesystemStorageOptions extends StorageOptions { - /** - * Base directory for cache files. - * @default '.frontmcp-cache/builds' - */ - cacheDir?: string; - - /** - * File extension for cache files. - * @default '.json' - */ - extension?: string; -} - -/** - * Default filesystem storage options. - */ -const DEFAULT_FS_OPTIONS: Required = { - ...DEFAULT_STORAGE_OPTIONS, - cacheDir: '.frontmcp-cache/builds', - extension: '.json', -}; - -/** - * Filesystem-based build cache storage. - * - * Stores each build manifest as a JSON file in the cache directory. - * Uses LRU eviction based on last access time when size limits are exceeded. - * - * Directory structure: - * ``` - * .frontmcp-cache/builds/ - * ├── {hash1}.json - * ├── {hash2}.json - * └── ... - * ``` - * - * @example - * ```typescript - * const storage = new FilesystemStorage({ - * cacheDir: '.cache/ui-builds', - * maxSize: 50 * 1024 * 1024, // 50MB - * }); - * - * await storage.initialize(); - * await storage.set('abc123', manifest); - * const cached = await storage.get('abc123'); - * ``` - */ -export class FilesystemStorage implements BuildCacheStorage { - readonly type = 'filesystem'; - - private readonly options: Required; - private initialized = false; - private stats: CacheStats = { - entries: 0, - totalSize: 0, - hits: 0, - misses: 0, - hitRate: 0, - }; - - constructor(options: FilesystemStorageOptions = {}) { - this.options = { - ...DEFAULT_FS_OPTIONS, - ...options, - }; - } - - /** - * Initialize the storage directory. - */ - async initialize(): Promise { - if (this.initialized) return; - - try { - await mkdir(this.options.cacheDir, { recursive: true }); - await this.loadStats(); - this.initialized = true; - } catch (error) { - throw new CacheInitializationError(`Failed to initialize cache directory: ${error}`, error); - } - } - - /** - * Get a cached manifest. - */ - async get(key: string): Promise { - this.ensureInitialized(); - - const filePath = this.getFilePath(key); - - try { - if (!existsSync(filePath)) { - this.stats.misses++; - this.updateHitRate(); - return undefined; - } - - const content = await readFile(filePath, 'utf8'); - const entry: CacheEntry = JSON.parse(content); - - // Check expiration - if (Date.now() > entry.metadata.expiresAt) { - await this.delete(key); - this.stats.misses++; - this.updateHitRate(); - return undefined; - } - - // Update access metadata - entry.metadata.lastAccessedAt = Date.now(); - entry.metadata.accessCount++; - - // Write back updated metadata (async, don't await) - this.writeEntry(filePath, entry).catch((err) => { - // Fire-and-forget write - log at debug level for visibility - if (process.env['DEBUG']) { - console.debug(`[FilesystemStorage] Failed to update cache metadata for ${key}: ${err}`); - } - }); - - this.stats.hits++; - this.updateHitRate(); - return entry.data; - } catch { - this.stats.misses++; - this.updateHitRate(); - return undefined; - } - } - - /** - * Store a manifest in cache. - */ - async set(key: string, manifest: ComponentBuildManifest, ttl?: number): Promise { - this.ensureInitialized(); - - const filePath = this.getFilePath(key); - const size = calculateManifestSize(manifest); - const effectiveTtl = ttl ?? this.options.defaultTtl; - - // Check if we need to evict entries - await this.ensureCapacity(size); - - const entry: CacheEntry = { - data: manifest, - metadata: { - key, - size, - createdAt: Date.now(), - expiresAt: Date.now() + effectiveTtl * 1000, - lastAccessedAt: Date.now(), - accessCount: 0, - }, - }; - - await this.writeEntry(filePath, entry); - - this.stats.entries++; - this.stats.totalSize += size; - } - - /** - * Check if a key exists. - */ - async has(key: string): Promise { - this.ensureInitialized(); - - const filePath = this.getFilePath(key); - - try { - if (!existsSync(filePath)) return false; - - const content = await readFile(filePath, 'utf8'); - const entry: CacheEntry = JSON.parse(content); - - // Check expiration - if (Date.now() > entry.metadata.expiresAt) { - await this.delete(key); - return false; - } - - return true; - } catch { - return false; - } - } - - /** - * Delete a cached entry. - */ - async delete(key: string): Promise { - this.ensureInitialized(); - - const filePath = this.getFilePath(key); - - try { - if (!existsSync(filePath)) return false; - - // Read to get size for stats - const content = await readFile(filePath, 'utf8'); - const entry: CacheEntry = JSON.parse(content); - - await unlink(filePath); - - this.stats.entries = Math.max(0, this.stats.entries - 1); - this.stats.totalSize = Math.max(0, this.stats.totalSize - entry.metadata.size); - - return true; - } catch { - return false; - } - } - - /** - * Clear all cached entries. - */ - async clear(): Promise { - this.ensureInitialized(); - - try { - await rm(this.options.cacheDir, { recursive: true, force: true }); - await mkdir(this.options.cacheDir, { recursive: true }); - - this.stats = { - entries: 0, - totalSize: 0, - hits: 0, - misses: 0, - hitRate: 0, - }; - } catch (error) { - throw new CacheOperationError(`Failed to clear cache: ${error}`, error); - } - } - - /** - * Get cache statistics. - */ - async getStats(): Promise { - return { ...this.stats }; - } - - /** - * Clean up expired entries. - */ - async cleanup(): Promise { - this.ensureInitialized(); - - let removed = 0; - - try { - const files = await readdir(this.options.cacheDir); - - for (const file of files) { - if (!file.endsWith(this.options.extension)) continue; - - const filePath = join(this.options.cacheDir, file); - - try { - const content = await readFile(filePath, 'utf8'); - const entry: CacheEntry = JSON.parse(content); - - if (Date.now() > entry.metadata.expiresAt) { - await unlink(filePath); - this.stats.entries = Math.max(0, this.stats.entries - 1); - this.stats.totalSize = Math.max(0, this.stats.totalSize - entry.metadata.size); - removed++; - } - } catch { - // Corrupted file, remove it - await unlink(filePath).catch(() => { - /* ignore removal errors */ - }); - removed++; - } - } - } catch { - // Directory doesn't exist or can't be read - } - - return removed; - } - - /** - * Close the storage (no-op for filesystem). - */ - async close(): Promise { - // Nothing to close for filesystem storage - } - - /** - * Get the file path for a cache key. - * Uses SHA-256 hash to avoid collisions from key sanitization. - */ - private getFilePath(key: string): string { - // Use hash to avoid collisions - different keys always produce different filenames - const hash = createHash('sha256').update(key).digest('hex').slice(0, 16); - return join(this.options.cacheDir, `${hash}${this.options.extension}`); - } - - /** - * Write a cache entry to disk. - */ - private async writeEntry(filePath: string, entry: CacheEntry): Promise { - await mkdir(dirname(filePath), { recursive: true }); - await writeFile(filePath, JSON.stringify(entry, null, 2), 'utf8'); - } - - /** - * Ensure the storage is initialized. - */ - private ensureInitialized(): void { - if (!this.initialized) { - throw new StorageNotInitializedError(); - } - } - - /** - * Load stats from existing cache files. - * Reads entry metadata to get accurate manifest sizes. - */ - private async loadStats(): Promise { - try { - const files = await readdir(this.options.cacheDir); - let entries = 0; - let totalSize = 0; - - for (const file of files) { - if (!file.endsWith(this.options.extension)) continue; - - const filePath = join(this.options.cacheDir, file); - - try { - const content = await readFile(filePath, 'utf8'); - const entry: CacheEntry = JSON.parse(content); - entries++; - totalSize += entry.metadata.size; - } catch { - // Skip unreadable or corrupted files - } - } - - this.stats.entries = entries; - this.stats.totalSize = totalSize; - } catch { - // Directory doesn't exist yet - } - } - - /** - * Ensure there's capacity for a new entry. - */ - private async ensureCapacity(newEntrySize: number): Promise { - // Check entry count limit - if (this.stats.entries >= this.options.maxEntries) { - await this.evictLRU(); - } - - // Check size limit - while (this.stats.totalSize + newEntrySize > this.options.maxSize) { - const evicted = await this.evictLRU(); - if (!evicted) break; // No more entries to evict - } - } - - /** - * Evict the least recently used entry. - */ - private async evictLRU(): Promise { - try { - const files = await readdir(this.options.cacheDir); - let oldestKey: string | null = null; - let oldestTime = Infinity; - let corruptedFile: string | null = null; - - for (const file of files) { - if (!file.endsWith(this.options.extension)) continue; - - const filePath = join(this.options.cacheDir, file); - - try { - const content = await readFile(filePath, 'utf8'); - const entry: CacheEntry = JSON.parse(content); - - if (entry.metadata.lastAccessedAt < oldestTime) { - oldestTime = entry.metadata.lastAccessedAt; - oldestKey = entry.metadata.key; - } - } catch { - // Corrupted file, mark for direct removal - corruptedFile = filePath; - } - } - - // Remove corrupted file directly if found - if (corruptedFile) { - try { - await unlink(corruptedFile); - this.stats.entries = Math.max(0, this.stats.entries - 1); - return true; - } catch { - return false; - } - } - - // Delete oldest entry using the key from metadata - if (oldestKey) { - return await this.delete(oldestKey); - } - - return false; - } catch { - return false; - } - } - - /** - * Update hit rate statistic. - */ - private updateHitRate(): void { - const total = this.stats.hits + this.stats.misses; - this.stats.hitRate = total > 0 ? this.stats.hits / total : 0; - } -} - -/** - * Create a filesystem storage instance. - */ -export function createFilesystemStorage(options?: FilesystemStorageOptions): FilesystemStorage { - return new FilesystemStorage(options); -} diff --git a/libs/ui/src/bundler/file-cache/storage/index.ts b/libs/ui/src/bundler/file-cache/storage/index.ts deleted file mode 100644 index 40b5dc0d..00000000 --- a/libs/ui/src/bundler/file-cache/storage/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Build Cache Storage - * - * Storage implementations for component build caching. - * - * @packageDocumentation - */ - -// Interface -export { - type BuildCacheStorage, - type StorageOptions, - type CacheEntry, - type CacheEntryMetadata, - DEFAULT_STORAGE_OPTIONS, - calculateManifestSize, -} from './interface'; - -// Filesystem Storage -export { FilesystemStorage, createFilesystemStorage, type FilesystemStorageOptions } from './filesystem'; - -// Redis Storage -export { RedisStorage, createRedisStorage, type RedisStorageOptions, type RedisClient } from './redis'; diff --git a/libs/ui/src/bundler/file-cache/storage/interface.ts b/libs/ui/src/bundler/file-cache/storage/interface.ts deleted file mode 100644 index c3671682..00000000 --- a/libs/ui/src/bundler/file-cache/storage/interface.ts +++ /dev/null @@ -1,189 +0,0 @@ -/** - * Build Cache Storage Interface - * - * Abstract interface for storing and retrieving component build manifests. - * Implementations include filesystem (development) and Redis (production). - * - * @packageDocumentation - */ - -import type { ComponentBuildManifest, CacheStats } from '@frontmcp/uipack/dependency'; - -/** - * Options for storage initialization. - */ -export interface StorageOptions { - /** - * Maximum number of entries to store. - * @default 1000 - */ - maxEntries?: number; - - /** - * Maximum total size in bytes. - * @default 104857600 (100MB) - */ - maxSize?: number; - - /** - * Default TTL in seconds. - * @default 86400 (24 hours) - */ - defaultTtl?: number; - - /** - * Whether to compress stored data. - * @default false - */ - compress?: boolean; -} - -/** - * Abstract interface for build cache storage. - * - * Implementations should handle: - * - Key-value storage of ComponentBuildManifest - * - TTL-based expiration - * - Size-based eviction - * - Concurrent access safety - */ -export interface BuildCacheStorage { - /** - * Storage identifier for logging/debugging. - */ - readonly type: string; - - /** - * Initialize the storage backend. - * Must be called before other operations. - */ - initialize(): Promise; - - /** - * Retrieve a cached build manifest by key. - * - * @param key - Cache key (typically content hash) - * @returns Cached manifest or undefined if not found/expired - */ - get(key: string): Promise; - - /** - * Store a build manifest in cache. - * - * @param key - Cache key - * @param manifest - Build manifest to store - * @param ttl - Optional TTL in seconds (overrides default) - */ - set(key: string, manifest: ComponentBuildManifest, ttl?: number): Promise; - - /** - * Check if a key exists in cache (and is not expired). - * - * @param key - Cache key to check - * @returns true if key exists and is valid - */ - has(key: string): Promise; - - /** - * Delete a cached entry. - * - * @param key - Cache key to delete - * @returns true if entry was deleted, false if not found - */ - delete(key: string): Promise; - - /** - * Clear all cached entries. - */ - clear(): Promise; - - /** - * Get cache statistics. - */ - getStats(): Promise; - - /** - * Clean up expired entries. - * Returns number of entries removed. - */ - cleanup(): Promise; - - /** - * Close the storage connection. - * Should be called when the application shuts down. - */ - close(): Promise; -} - -/** - * Metadata for a cached entry. - */ -export interface CacheEntryMetadata { - /** - * Cache key. - */ - key: string; - - /** - * Size of the cached data in bytes. - */ - size: number; - - /** - * Timestamp when entry was created (ms since epoch). - */ - createdAt: number; - - /** - * Timestamp when entry expires (ms since epoch). - */ - expiresAt: number; - - /** - * Last access timestamp (ms since epoch). - */ - lastAccessedAt: number; - - /** - * Number of times the entry has been accessed. - */ - accessCount: number; -} - -/** - * Wrapper around cached data with metadata. - */ -export interface CacheEntry { - /** - * The cached data. - */ - data: T; - - /** - * Entry metadata. - */ - metadata: CacheEntryMetadata; -} - -/** - * Default storage options. - */ -export const DEFAULT_STORAGE_OPTIONS: Required = { - maxEntries: 1000, - maxSize: 100 * 1024 * 1024, // 100MB - defaultTtl: 24 * 60 * 60, // 24 hours - compress: false, -}; - -/** - * Calculate the size of a manifest in bytes. - */ -export function calculateManifestSize(manifest: ComponentBuildManifest): number { - // Approximate size by serializing to JSON - try { - return Buffer.byteLength(JSON.stringify(manifest), 'utf8'); - } catch { - // Fallback for circular references or BigInt values - return 0; - } -} diff --git a/libs/ui/src/bundler/file-cache/storage/redis.ts b/libs/ui/src/bundler/file-cache/storage/redis.ts deleted file mode 100644 index 902805ca..00000000 --- a/libs/ui/src/bundler/file-cache/storage/redis.ts +++ /dev/null @@ -1,398 +0,0 @@ -/** - * Redis Build Cache Storage - * - * Redis-based cache storage for production environments. - * Uses Redis TTL for expiration and optional compression. - * - * @packageDocumentation - */ - -import type { ComponentBuildManifest, CacheStats } from '@frontmcp/uipack/dependency'; -import type { BuildCacheStorage, StorageOptions, CacheEntry } from './interface'; -import { DEFAULT_STORAGE_OPTIONS, calculateManifestSize } from './interface'; - -/** - * Redis client interface (compatible with ioredis and redis packages). - */ -export interface RedisClient { - get(key: string): Promise; - set(key: string, value: string, mode?: string, duration?: number): Promise; - setex(key: string, seconds: number, value: string): Promise; - del(key: string | string[]): Promise; - exists(key: string | string[]): Promise; - keys(pattern: string): Promise; - ttl(key: string): Promise; - expire(key: string, seconds: number): Promise; - quit(): Promise; - ping(): Promise; -} - -/** - * Options specific to Redis storage. - */ -export interface RedisStorageOptions extends StorageOptions { - /** - * Redis client instance. - * Must be provided. - */ - client: RedisClient; - - /** - * Key prefix for cache entries. - * @default 'frontmcp:ui:build:' - */ - keyPrefix?: string; - - /** - * Whether to use JSON.stringify/parse (vs raw storage). - * @default true - */ - json?: boolean; -} - -/** - * Key for storing cache statistics. - */ -const STATS_KEY_SUFFIX = ':__stats__'; - -/** - * Redis-based build cache storage. - * - * Stores build manifests in Redis with automatic TTL expiration. - * Suitable for production environments with multiple server instances. - * - * @example - * ```typescript - * import Redis from 'ioredis'; - * - * const redis = new Redis(process.env.REDIS_URL); - * const storage = new RedisStorage({ - * client: redis, - * keyPrefix: 'myapp:builds:', - * defaultTtl: 3600, // 1 hour - * }); - * - * await storage.initialize(); - * await storage.set('abc123', manifest); - * ``` - */ -export class RedisStorage implements BuildCacheStorage { - readonly type = 'redis'; - - private readonly options: Required> & { client: RedisClient }; - private initialized = false; - private localStats: CacheStats = { - entries: 0, - totalSize: 0, - hits: 0, - misses: 0, - hitRate: 0, - }; - - constructor(options: RedisStorageOptions) { - if (!options.client) { - throw new Error('Redis client is required'); - } - - this.options = { - ...DEFAULT_STORAGE_OPTIONS, - keyPrefix: 'frontmcp:ui:build:', - json: true, - ...options, - }; - } - - /** - * Initialize the Redis connection. - */ - async initialize(): Promise { - if (this.initialized) return; - - try { - // Test connection - await this.options.client.ping(); - - // Load stats from Redis - await this.loadStats(); - - this.initialized = true; - } catch (error) { - throw new Error(`Failed to connect to Redis: ${error}`); - } - } - - /** - * Get a cached manifest. - */ - async get(key: string): Promise { - this.ensureInitialized(); - - const redisKey = this.getRedisKey(key); - - try { - const data = await this.options.client.get(redisKey); - - if (!data) { - this.localStats.misses++; - this.updateHitRate(); - await this.persistStats(); - return undefined; - } - - // CacheEntry must always be JSON parsed to access metadata structure - const entry: CacheEntry = JSON.parse(data); - - // Update access metadata - entry.metadata.lastAccessedAt = Date.now(); - entry.metadata.accessCount++; - - // Get remaining TTL and persist updated entry - const ttl = await this.options.client.ttl(redisKey); - if (ttl > 0) { - // Re-set with same TTL to update metadata - // CacheEntry must always be JSON serialized to maintain metadata structure - const serialized = JSON.stringify(entry); - await this.options.client.setex(redisKey, ttl, serialized); - } - - this.localStats.hits++; - this.updateHitRate(); - await this.persistStats(); - - return entry.data; - } catch (error) { - // Log for debugging but still return undefined for cache miss - console.warn?.(`Redis cache get failed for key "${key}": ${error}`); - this.localStats.misses++; - this.updateHitRate(); - // Persist stats on error path too for consistency - await this.persistStats().catch(() => { - /* Ignore stats persistence errors in error path */ - }); - return undefined; - } - } - - /** - * Store a manifest in cache. - */ - async set(key: string, manifest: ComponentBuildManifest, ttl?: number): Promise { - this.ensureInitialized(); - - const redisKey = this.getRedisKey(key); - const size = calculateManifestSize(manifest); - const effectiveTtl = ttl ?? this.options.defaultTtl; - - const entry: CacheEntry = { - data: manifest, - metadata: { - key, - size, - createdAt: Date.now(), - expiresAt: Date.now() + effectiveTtl * 1000, - lastAccessedAt: Date.now(), - accessCount: 0, - }, - }; - - // CacheEntry must always be JSON serialized to maintain metadata structure - const serialized = JSON.stringify(entry); - - await this.options.client.setex(redisKey, effectiveTtl, serialized); - - this.localStats.entries++; - this.localStats.totalSize += size; - await this.persistStats(); - } - - /** - * Check if a key exists. - */ - async has(key: string): Promise { - this.ensureInitialized(); - - const redisKey = this.getRedisKey(key); - const exists = await this.options.client.exists(redisKey); - return exists > 0; - } - - /** - * Delete a cached entry. - */ - async delete(key: string): Promise { - this.ensureInitialized(); - - const redisKey = this.getRedisKey(key); - - // Try to get size for stats before deleting - try { - const data = await this.options.client.get(redisKey); - if (data) { - // CacheEntry must always be JSON parsed to access metadata structure - const entry: CacheEntry = JSON.parse(data); - this.localStats.totalSize = Math.max(0, this.localStats.totalSize - entry.metadata.size); - } - } catch { - // Ignore errors - } - - const deleted = await this.options.client.del(redisKey); - - if (deleted > 0) { - this.localStats.entries = Math.max(0, this.localStats.entries - 1); - await this.persistStats(); - return true; - } - - return false; - } - - /** - * Clear all cached entries. - */ - async clear(): Promise { - this.ensureInitialized(); - - const pattern = `${this.options.keyPrefix}*`; - const keys = await this.options.client.keys(pattern); - - if (keys.length > 0) { - await this.options.client.del(keys); - } - - this.localStats = { - entries: 0, - totalSize: 0, - hits: 0, - misses: 0, - hitRate: 0, - }; - - await this.persistStats(); - } - - /** - * Get cache statistics. - */ - async getStats(): Promise { - await this.loadStats(); - return { ...this.localStats }; - } - - /** - * Clean up expired entries. - * Redis handles TTL expiration automatically, so this just refreshes stats. - */ - async cleanup(): Promise { - this.ensureInitialized(); - - // Count actual keys - const pattern = `${this.options.keyPrefix}*`; - const keys = await this.options.client.keys(pattern); - - // Filter out stats key - const dataKeys = keys.filter((k) => !k.endsWith(STATS_KEY_SUFFIX)); - const previousCount = this.localStats.entries; - - this.localStats.entries = dataKeys.length; - - // Recalculate total size (expensive, but accurate) - let totalSize = 0; - for (const key of dataKeys) { - try { - const data = await this.options.client.get(key); - if (data) { - // CacheEntry must always be JSON parsed to access metadata structure - const entry: CacheEntry = JSON.parse(data); - totalSize += entry.metadata.size; - } - } catch { - // Skip corrupted entries - } - } - - this.localStats.totalSize = totalSize; - await this.persistStats(); - - return Math.max(0, previousCount - this.localStats.entries); - } - - /** - * Close the Redis connection. - */ - async close(): Promise { - await this.options.client.quit(); - } - - /** - * Get the Redis key for a cache key. - */ - private getRedisKey(key: string): string { - return `${this.options.keyPrefix}${key}`; - } - - /** - * Get the Redis key for stats. - */ - private getStatsKey(): string { - return `${this.options.keyPrefix}${STATS_KEY_SUFFIX}`; - } - - /** - * Ensure the storage is initialized. - */ - private ensureInitialized(): void { - if (!this.initialized) { - throw new Error('Storage not initialized. Call initialize() first.'); - } - } - - /** - * Load stats from Redis. - */ - private async loadStats(): Promise { - try { - const statsKey = this.getStatsKey(); - const data = await this.options.client.get(statsKey); - - if (data) { - const savedStats = JSON.parse(data); - this.localStats = { - ...this.localStats, - ...savedStats, - }; - } - } catch { - // Use default stats - } - } - - /** - * Persist stats to Redis. - */ - private async persistStats(): Promise { - try { - const statsKey = this.getStatsKey(); - const serialized = JSON.stringify(this.localStats); - // Stats don't expire - await this.options.client.set(statsKey, serialized); - } catch { - // Ignore errors - } - } - - /** - * Update hit rate statistic. - */ - private updateHitRate(): void { - const total = this.localStats.hits + this.localStats.misses; - this.localStats.hitRate = total > 0 ? this.localStats.hits / total : 0; - } -} - -/** - * Create a Redis storage instance. - */ -export function createRedisStorage(options: RedisStorageOptions): RedisStorage { - return new RedisStorage(options); -} diff --git a/libs/ui/src/bundler/index.ts b/libs/ui/src/bundler/index.ts index 3429971f..6f0cd568 100644 --- a/libs/ui/src/bundler/index.ts +++ b/libs/ui/src/bundler/index.ts @@ -95,29 +95,35 @@ export { } from './types'; // ============================================ -// Cache Utilities +// Cache Utilities (re-exported from @frontmcp/uipack) // ============================================ -export { BundlerCache, hashContent, createCacheKey } from './cache'; +export { BundlerCache, hashContent, createCacheKey } from '@frontmcp/uipack/bundler'; -export type { CacheOptions, CacheStats } from './cache'; +export type { CacheOptions, CacheStats } from '@frontmcp/uipack/bundler'; // ============================================ -// Security Utilities +// Security Utilities (re-exported from @frontmcp/uipack) // ============================================ -export { validateSource, validateImports, validateSize, mergePolicy, throwOnViolations } from './sandbox/policy'; +export { + validateSource, + validateImports, + validateSize, + mergePolicy, + throwOnViolations, +} from '@frontmcp/uipack/bundler'; // ============================================ -// Execution Utilities +// Execution Utilities (re-exported from @frontmcp/uipack) // ============================================ -export { executeCode, executeDefault, isExecutionError } from './sandbox/executor'; +export { executeCode, executeDefault, isExecutionError } from '@frontmcp/uipack/bundler'; -export type { ExecutionContext, ExecutionResult } from './sandbox/executor'; +export type { ExecutionContext, ExecutionResult } from '@frontmcp/uipack/bundler'; // ============================================ -// File-Based Component Caching +// File-Based Component Caching (re-exported from @frontmcp/uipack) // ============================================ export { @@ -154,4 +160,4 @@ export { createRedisBuilder, type ComponentBuildOptions, type ComponentBuildResult, -} from './file-cache'; +} from '@frontmcp/uipack/bundler'; diff --git a/libs/ui/src/bundler/sandbox/enclave-adapter.ts b/libs/ui/src/bundler/sandbox/enclave-adapter.ts deleted file mode 100644 index 100b5b8b..00000000 --- a/libs/ui/src/bundler/sandbox/enclave-adapter.ts +++ /dev/null @@ -1,477 +0,0 @@ -/** - * Enclave-VM Secure Code Executor - * - * Executes bundled code in a secure sandbox using enclave-vm. - * Provides defense-in-depth security with: - * - AST-based validation (81+ blocked attack vectors) - * - Timeout enforcement (default 5000ms) - * - Resource limits (maxIterations, maxToolCalls) - * - Six security layers - * - * @packageDocumentation - */ - -import { Enclave, type CreateEnclaveOptions, type SecurityLevel } from 'enclave-vm'; -import type { SecurityPolicy } from '../types'; - -/** - * Context for code execution. - */ -export interface ExecutionContext { - /** - * React module to inject. - */ - React?: unknown; - - /** - * ReactDOM module to inject. - */ - ReactDOM?: unknown; - - /** - * Additional modules to inject. - */ - modules?: Record; - - /** - * Additional global variables. - */ - globals?: Record; - - /** - * Security policy to enforce. - */ - security?: SecurityPolicy; - - /** - * Execution timeout in milliseconds. - * @default 5000 - */ - timeout?: number; - - /** - * Maximum loop iterations allowed. - * @default 10000 - */ - maxIterations?: number; -} - -/** - * Result of code execution. - */ -export interface ExecutionResult { - /** - * Exported value from the code. - */ - exports: T; - - /** - * Execution time in ms. - */ - executionTime: number; - - /** - * Console output captured during execution. - */ - consoleOutput?: string[]; -} - -/** - * Default enclave options for secure widget execution. - */ -const DEFAULT_ENCLAVE_OPTIONS: Partial = { - securityLevel: 'SECURE', - timeout: 5000, - maxIterations: 10000, - validate: true, - transform: true, -}; - -/** - * Threshold of blocked imports that triggers STRICT security level. - * When a SecurityPolicy blocks more than this many imports, we escalate to STRICT. - */ -const STRICT_SECURITY_BLOCKED_IMPORTS_THRESHOLD = 10; - -/** - * Map SecurityPolicy to enclave-vm security level. - */ -function mapSecurityLevel(policy?: SecurityPolicy): SecurityLevel { - // If policy has specific blockedImports or restrictive settings, use STRICT - if (policy?.blockedImports && policy.blockedImports.length > STRICT_SECURITY_BLOCKED_IMPORTS_THRESHOLD) { - return 'STRICT'; - } - // Default to SECURE for widget code - return 'SECURE'; -} - -/** - * Create a minimal JSX runtime from React. - */ -function createJSXRuntime(React: unknown): Record { - const R = React as { - createElement: (...args: unknown[]) => unknown; - Fragment: unknown; - }; - - return { - jsx: (type: unknown, props: Record, key?: string) => { - const { children, ...rest } = props; - return R.createElement(type, key ? { ...rest, key } : rest, children); - }, - jsxs: (type: unknown, props: Record, key?: string) => { - const { children, ...rest } = props; - return R.createElement(type, key ? { ...rest, key } : rest, children); - }, - jsxDEV: ( - type: unknown, - props: Record, - key: string | undefined, - _isStaticChildren: boolean, - _source: unknown, - _self: unknown, - ) => { - const { children, ...rest } = props; - return R.createElement(type, key ? { ...rest, key } : rest, children); - }, - Fragment: R.Fragment, - }; -} - -/** - * Dangerous global keys that should never be injected from user context. - * These could potentially bypass enclave security if allowed. - */ -const DANGEROUS_GLOBAL_KEYS = new Set([ - 'process', - 'require', - '__dirname', - '__filename', - 'Buffer', - 'eval', - 'Function', - 'constructor', - 'global', - 'globalThis', - 'module', - 'exports', - '__proto__', -]); - -/** - * Sanitize a key for use as a global variable name. - * Replaces non-alphanumeric characters (except _ and $) with underscores. - */ -function sanitizeGlobalKey(key: string): string { - return key.replace(/[^a-zA-Z0-9_$]/g, '_'); -} - -/** - * Build globals object from execution context. - */ -function buildGlobals(context: ExecutionContext): Record { - const globals: Record = {}; - - // Add React and ReactDOM if provided - if (context.React) { - globals['React'] = context.React; - } - if (context.ReactDOM) { - globals['ReactDOM'] = context.ReactDOM; - } - - // Add JSX runtime if React is available - if (context.React) { - const jsxRuntime = createJSXRuntime(context.React); - globals['__jsx'] = jsxRuntime['jsx']; - globals['__jsxs'] = jsxRuntime['jsxs']; - globals['__jsxDEV'] = jsxRuntime['jsxDEV']; - globals['Fragment'] = jsxRuntime['Fragment']; - } - - // Add modules as globals (enclave-vm handles require internally) - if (context.modules) { - for (const [key, value] of Object.entries(context.modules)) { - // Sanitize key and make modules accessible as globals - const sanitizedKey = sanitizeGlobalKey(key); - if (DANGEROUS_GLOBAL_KEYS.has(sanitizedKey)) { - throw new ExecutionError( - `Dangerous module key '${key}' (sanitized: '${sanitizedKey}') is not allowed in execution context`, - { code: 'SECURITY_VIOLATION' }, - ); - } - globals[sanitizedKey] = value; - } - } - - // Add user globals with security filtering - if (context.globals) { - for (const [key, value] of Object.entries(context.globals)) { - // Check for dangerous keys (both original and sanitized) - if (DANGEROUS_GLOBAL_KEYS.has(key)) { - throw new ExecutionError(`Dangerous global key '${key}' is not allowed in execution context`, { - code: 'SECURITY_VIOLATION', - }); - } - // Sanitize the key for safe global variable naming - const sanitizedKey = sanitizeGlobalKey(key); - if (DANGEROUS_GLOBAL_KEYS.has(sanitizedKey)) { - throw new ExecutionError( - `Dangerous global key '${key}' (sanitized: '${sanitizedKey}') is not allowed in execution context`, - { code: 'SECURITY_VIOLATION' }, - ); - } - globals[sanitizedKey] = value; - } - } - - return globals; -} - -/** - * Build require function for module resolution. - */ -function buildRequireFunction(context: ExecutionContext): (id: string) => unknown { - // Normalize all context.modules keys to lowercase for consistent lookup - const normalizedContextModules: Record = {}; - if (context.modules) { - for (const [key, value] of Object.entries(context.modules)) { - normalizedContextModules[key.toLowerCase()] = value; - } - } - - const modules: Record = { - react: context.React, - 'react-dom': context.ReactDOM, - 'react/jsx-runtime': context.React ? createJSXRuntime(context.React) : undefined, - 'react/jsx-dev-runtime': context.React ? createJSXRuntime(context.React) : undefined, - ...normalizedContextModules, - }; - - return (id: string): unknown => { - const normalizedId = id.toLowerCase(); - - if (normalizedId in modules) { - const mod = modules[normalizedId]; - if (mod === undefined) { - throw new Error(`Module '${id}' is not available. Did you forget to provide it in the context?`); - } - return mod; - } - - throw new Error(`Module '${id}' is not available in the sandbox environment`); - }; -} - -/** - * Execute bundled code in a secure enclave-vm sandbox. - * - * Provides a sandboxed execution context with: - * - AST-based code validation (81+ attack vectors blocked) - * - Timeout enforcement (default 5000ms) - * - Resource limits (maxIterations) - * - Six security layers (defense-in-depth) - * - * @param code - Bundled JavaScript code - * @param context - Execution context - * @returns Execution result with exports - * - * @example - * ```typescript - * const code = ` - * const React = require('react'); - * function Widget({ data }) { - * return React.createElement('div', null, data.message); - * } - * module.exports = Widget; - * `; - * - * const result = await executeCode(code, { - * React: require('react'), - * timeout: 3000, - * }); - * - * console.log(result.exports); // Widget function - * ``` - */ -export async function executeCode( - code: string, - context: ExecutionContext = {}, -): Promise> { - const consoleOutput: string[] = []; - - // Build globals with console capture - const globals = buildGlobals(context); - - // Add sandboxed console - globals['console'] = { - log: (...args: unknown[]) => { - consoleOutput.push(args.map(String).join(' ')); - }, - info: (...args: unknown[]) => { - consoleOutput.push(`[INFO] ${args.map(String).join(' ')}`); - }, - warn: (...args: unknown[]) => { - consoleOutput.push(`[WARN] ${args.map(String).join(' ')}`); - }, - error: (...args: unknown[]) => { - consoleOutput.push(`[ERROR] ${args.map(String).join(' ')}`); - }, - debug: (...args: unknown[]) => { - consoleOutput.push(`[DEBUG] ${args.map(String).join(' ')}`); - }, - trace: () => { - /* noop */ - }, - dir: () => { - /* noop */ - }, - table: () => { - /* noop */ - }, - group: () => { - /* noop */ - }, - groupEnd: () => { - /* noop */ - }, - time: () => { - /* noop */ - }, - timeEnd: () => { - /* noop */ - }, - assert: () => { - /* noop */ - }, - clear: () => { - /* noop */ - }, - count: () => { - /* noop */ - }, - countReset: () => { - /* noop */ - }, - }; - - // Add require function - globals['require'] = buildRequireFunction(context); - - // Create enclave with options - const enclave = new Enclave({ - ...DEFAULT_ENCLAVE_OPTIONS, - timeout: context.timeout ?? DEFAULT_ENCLAVE_OPTIONS.timeout, - maxIterations: context.maxIterations ?? DEFAULT_ENCLAVE_OPTIONS.maxIterations, - securityLevel: mapSecurityLevel(context.security), - globals, - allowFunctionsInGlobals: true, // Required for React components - }); - - try { - // Wrap code in module pattern to match CommonJS behavior - const wrappedCode = ` - const module = { exports: {} }; - const exports = module.exports; - const __filename = 'widget.js'; - const __dirname = '/'; - ${code} - return module.exports; - `; - - const result = await enclave.run(wrappedCode); - - if (!result.success) { - const errorMessage = result.error?.message ?? 'Execution failed'; - const errorCode = result.error?.code; - - // Map enclave error codes to descriptive messages - if (errorCode === 'TIMEOUT') { - throw new ExecutionError(`Execution timed out after ${context.timeout ?? DEFAULT_ENCLAVE_OPTIONS.timeout}ms`, { - code: 'TIMEOUT', - }); - } - if (errorCode === 'MAX_ITERATIONS') { - throw new ExecutionError( - `Maximum iterations exceeded (${context.maxIterations ?? DEFAULT_ENCLAVE_OPTIONS.maxIterations})`, - { - code: 'MAX_ITERATIONS', - }, - ); - } - if (errorCode === 'VALIDATION_ERROR') { - throw new ExecutionError(`Security validation failed: ${errorMessage}`, { code: 'SECURITY_VIOLATION' }); - } - - throw new ExecutionError(errorMessage, result.error); - } - - return { - exports: result.value as T, - executionTime: result.stats.duration, - consoleOutput: consoleOutput.length > 0 ? consoleOutput : undefined, - }; - } finally { - enclave.dispose(); - } -} - -/** - * Execute bundled code and extract the default export. - * - * Convenience wrapper around executeCode that extracts - * the default export. - * - * @param code - Bundled JavaScript code - * @param context - Execution context - * @returns Default export from the code - */ -export async function executeDefault(code: string, context: ExecutionContext = {}): Promise { - const result = await executeCode<{ default?: T } & Record>(code, context); - - // Check for default export - if ('default' in result.exports) { - return result.exports.default as T; - } - - // Check for named exports - const exportKeys = Object.keys(result.exports); - - // Handle empty exports - throw error as code should export something - if (exportKeys.length === 0) { - throw new ExecutionError('Code did not export any values'); - } - - // If only one named export, return it as the default - if (exportKeys.length === 1) { - return result.exports[exportKeys[0]] as T; - } - - // Multiple exports - return the whole exports object - return result.exports as T; -} - -/** - * Error thrown during code execution. - */ -export class ExecutionError extends Error { - /** Error code for categorization */ - code?: string; - - constructor(message: string, cause?: unknown) { - super(message, { cause }); - this.name = 'ExecutionError'; - - // Extract code from cause if present - if (cause && typeof cause === 'object' && 'code' in cause) { - this.code = (cause as { code: string }).code; - } - } -} - -/** - * Check if an error is an ExecutionError. - */ -export function isExecutionError(error: unknown): error is ExecutionError { - return error instanceof ExecutionError; -} diff --git a/libs/ui/src/bundler/sandbox/executor.ts b/libs/ui/src/bundler/sandbox/executor.ts deleted file mode 100644 index 4f9d99ee..00000000 --- a/libs/ui/src/bundler/sandbox/executor.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Secure Code Executor - * - * Executes bundled code in a secure sandbox using enclave-vm. - * Provides defense-in-depth security with: - * - AST-based validation (81+ blocked attack vectors) - * - Timeout enforcement (default 5000ms) - * - Resource limits (maxIterations) - * - Six security layers - * - * @packageDocumentation - */ - -// Re-export everything from enclave-adapter -export { - executeCode, - executeDefault, - ExecutionError, - isExecutionError, - type ExecutionContext, - type ExecutionResult, -} from './enclave-adapter'; diff --git a/libs/ui/src/bundler/sandbox/policy.ts b/libs/ui/src/bundler/sandbox/policy.ts deleted file mode 100644 index d4a1695c..00000000 --- a/libs/ui/src/bundler/sandbox/policy.ts +++ /dev/null @@ -1,256 +0,0 @@ -/** - * Sandbox Security Policy - * - * Defines and validates security policies for bundler execution. - * - * @packageDocumentation - */ - -import type { SecurityPolicy, SecurityViolation } from '../types'; -import { DEFAULT_SECURITY_POLICY } from '../types'; - -/** - * Patterns that indicate unsafe code. - */ -const UNSAFE_PATTERNS = { - eval: /\beval\s*\(/g, - functionConstructor: /\bnew\s+Function\s*\(/g, - dynamicImport: /\bimport\s*\(/g, - require: /\brequire\s*\(/g, - processEnv: /\bprocess\.env\b/g, - globalThis: /\bglobalThis\b/g, - windowLocation: /\bwindow\.location\b/g, - documentCookie: /\bdocument\.cookie\b/g, - innerHTML: /\.innerHTML\s*=/g, - outerHTML: /\.outerHTML\s*=/g, - document_write: /\bdocument\.write\s*\(/g, -}; - -/** - * Validate source code against a security policy. - * - * @param source - Source code to validate - * @param policy - Security policy to enforce - * @returns Array of security violations (empty if valid) - * - * @example - * ```typescript - * const violations = validateSource(code, DEFAULT_SECURITY_POLICY); - * if (violations.length > 0) { - * throw new Error(`Security violations: ${violations.map(v => v.message).join(', ')}`); - * } - * ``` - */ -export function validateSource(source: string, policy: SecurityPolicy = DEFAULT_SECURITY_POLICY): SecurityViolation[] { - const violations: SecurityViolation[] = []; - - // Check for eval usage - if (policy.noEval !== false) { - const evalMatches = [...source.matchAll(UNSAFE_PATTERNS.eval)]; - for (const match of evalMatches) { - violations.push({ - type: 'eval-usage', - message: 'eval() is not allowed for security reasons', - location: getLocation(source, match.index ?? 0), - value: match[0], - }); - } - - const fnMatches = [...source.matchAll(UNSAFE_PATTERNS.functionConstructor)]; - for (const match of fnMatches) { - violations.push({ - type: 'eval-usage', - message: 'new Function() is not allowed for security reasons', - location: getLocation(source, match.index ?? 0), - value: match[0], - }); - } - } - - // Check for dynamic imports - if (policy.noDynamicImports !== false) { - const matches = [...source.matchAll(UNSAFE_PATTERNS.dynamicImport)]; - for (const match of matches) { - violations.push({ - type: 'dynamic-import', - message: 'Dynamic imports are not allowed for security reasons', - location: getLocation(source, match.index ?? 0), - value: match[0], - }); - } - } - - // Check for require usage - if (policy.noRequire !== false) { - const matches = [...source.matchAll(UNSAFE_PATTERNS.require)]; - for (const match of matches) { - violations.push({ - type: 'require-usage', - message: 'require() is not allowed for security reasons', - location: getLocation(source, match.index ?? 0), - value: match[0], - }); - } - } - - // Validate imports - const importViolations = validateImports(source, policy); - violations.push(...importViolations); - - return violations; -} - -/** - * Validate import statements against policy. - * - * @param source - Source code to check - * @param policy - Security policy - * @returns Array of import violations - */ -export function validateImports(source: string, policy: SecurityPolicy = DEFAULT_SECURITY_POLICY): SecurityViolation[] { - const violations: SecurityViolation[] = []; - - // Extract import statements - const importPattern = /import\s+(?:(?:\{[^}]*\}|[\w*]+)\s+from\s+)?['"]([^'"]+)['"]/g; - const imports: Array<{ module: string; index: number }> = []; - - let match; - while ((match = importPattern.exec(source)) !== null) { - imports.push({ module: match[1], index: match.index }); - } - - // Also check for require-style imports that might slip through - const requirePattern = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g; - while ((match = requirePattern.exec(source)) !== null) { - imports.push({ module: match[1], index: match.index }); - } - - for (const imp of imports) { - // Check blocked imports first - if (policy.blockedImports) { - for (const blocked of policy.blockedImports) { - if (blocked.test(imp.module)) { - violations.push({ - type: 'blocked-import', - message: `Import '${imp.module}' is blocked by security policy`, - location: getLocation(source, imp.index), - value: imp.module, - }); - break; - } - } - } - - // Check if import is in allowed list - if (policy.allowedImports && policy.allowedImports.length > 0) { - const isAllowed = policy.allowedImports.some((pattern) => pattern.test(imp.module)); - if (!isAllowed) { - // Check if already reported as blocked - const alreadyBlocked = violations.some((v) => v.type === 'blocked-import' && v.value === imp.module); - if (!alreadyBlocked) { - violations.push({ - type: 'disallowed-import', - message: `Import '${imp.module}' is not in the allowed imports list`, - location: getLocation(source, imp.index), - value: imp.module, - }); - } - } - } - } - - return violations; -} - -/** - * Validate bundle size against policy. - * - * @param size - Bundle size in bytes - * @param policy - Security policy - * @returns Violation if size exceeds limit, undefined otherwise - */ -export function validateSize( - size: number, - policy: SecurityPolicy = DEFAULT_SECURITY_POLICY, -): SecurityViolation | undefined { - const maxSize = policy.maxBundleSize ?? DEFAULT_SECURITY_POLICY.maxBundleSize ?? 512000; - - if (size > maxSize) { - return { - type: 'size-exceeded', - message: `Bundle size (${formatBytes(size)}) exceeds maximum allowed (${formatBytes(maxSize)})`, - value: String(size), - }; - } - - return undefined; -} - -/** - * Create a merged security policy with defaults. - * - * @param userPolicy - User-provided policy overrides - * @returns Merged policy with defaults - */ -export function mergePolicy(userPolicy?: Partial): SecurityPolicy { - if (!userPolicy) { - return { ...DEFAULT_SECURITY_POLICY }; - } - - return { - allowedImports: userPolicy.allowedImports ?? DEFAULT_SECURITY_POLICY.allowedImports, - blockedImports: userPolicy.blockedImports ?? DEFAULT_SECURITY_POLICY.blockedImports, - maxBundleSize: userPolicy.maxBundleSize ?? DEFAULT_SECURITY_POLICY.maxBundleSize, - maxTransformTime: userPolicy.maxTransformTime ?? DEFAULT_SECURITY_POLICY.maxTransformTime, - noEval: userPolicy.noEval ?? DEFAULT_SECURITY_POLICY.noEval, - noDynamicImports: userPolicy.noDynamicImports ?? DEFAULT_SECURITY_POLICY.noDynamicImports, - noRequire: userPolicy.noRequire ?? DEFAULT_SECURITY_POLICY.noRequire, - allowedGlobals: userPolicy.allowedGlobals ?? DEFAULT_SECURITY_POLICY.allowedGlobals, - }; -} - -/** - * Get line and column from source index. - */ -function getLocation(source: string, index: number): { line: number; column: number } { - const lines = source.slice(0, index).split('\n'); - return { - line: lines.length, - column: lines[lines.length - 1].length + 1, - }; -} - -/** - * Format bytes to human-readable string. - */ -function formatBytes(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; -} - -/** - * Security error thrown when policy is violated. - */ -export class SecurityError extends Error { - readonly violations: SecurityViolation[]; - - constructor(message: string, violations: SecurityViolation[]) { - super(message); - this.name = 'SecurityError'; - this.violations = violations; - } -} - -/** - * Throw if any violations exist. - * - * @param violations - Array of violations to check - * @throws SecurityError if violations exist - */ -export function throwOnViolations(violations: SecurityViolation[]): void { - if (violations.length > 0) { - const message = violations.map((v) => v.message).join('; '); - throw new SecurityError(`Security policy violation: ${message}`, violations); - } -} diff --git a/libs/ui/src/index.ts b/libs/ui/src/index.ts index b65c0aae..45a54aa5 100644 --- a/libs/ui/src/index.ts +++ b/libs/ui/src/index.ts @@ -39,16 +39,6 @@ export * from './components'; // ============================================ export * from './layouts'; -// ============================================ -// Page Templates -// ============================================ -export * from './pages'; - -// ============================================ -// Widgets (OpenAI App SDK, progress, etc.) -// ============================================ -export * from './widgets'; - // ============================================ // MCP Bridge // ============================================ @@ -99,22 +89,10 @@ export { // ============================================ export * from './react'; -// ============================================ -// React 19 Static Rendering -// ============================================ -export * from './render'; - // ============================================ // React Renderer and Adapter // ============================================ -export { - ReactRenderer, - reactRenderer, - buildHydrationScript, - ReactRendererAdapter, - createReactAdapter, - loadReactAdapter, -} from './renderers'; +export { ReactRenderer, reactRenderer, ReactRendererAdapter, createReactAdapter, loadReactAdapter } from './renderers'; // ============================================ // Universal Renderer (separate import path) diff --git a/libs/ui/src/pages/consent.ts b/libs/ui/src/pages/consent.ts deleted file mode 100644 index 88b44c7e..00000000 --- a/libs/ui/src/pages/consent.ts +++ /dev/null @@ -1,393 +0,0 @@ -/** - * OAuth Consent Page Template - * - * OAuth/OpenID Connect consent page for authorization flows. - */ - -import { consentLayout, type ConsentLayoutOptions } from '../layouts'; -import { primaryButton, outlineButton } from '../components/button'; -import { permissionList, type PermissionItem } from '../components/list'; -import { csrfInput, hiddenInput } from '../components/form'; -import { alert } from '../components/alert'; -import { escapeHtml } from '../layouts/base'; - -// ============================================ -// Consent Page Types -// ============================================ - -/** - * Client/Application information - */ -export interface ClientInfo { - /** Client ID */ - clientId: string; - /** Client name */ - name: string; - /** Client icon/logo URL */ - icon?: string; - /** Client website URL */ - websiteUrl?: string; - /** Privacy policy URL */ - privacyUrl?: string; - /** Terms of service URL */ - termsUrl?: string; - /** Verified client badge */ - verified?: boolean; -} - -/** - * User information - */ -export interface UserInfo { - /** User ID */ - id?: string; - /** User name */ - name?: string; - /** User email */ - email?: string; - /** Avatar URL */ - avatar?: string; -} - -/** - * Consent page options - */ -export interface ConsentPageOptions { - /** Client/App requesting authorization */ - client: ClientInfo; - /** Current user info */ - user?: UserInfo; - /** Requested permissions/scopes */ - permissions: PermissionItem[]; - /** Form action URL for approval */ - approveUrl: string; - /** Form action URL for denial (optional, uses approveUrl if not provided) */ - denyUrl?: string; - /** CSRF token */ - csrfToken?: string; - /** OAuth state parameter */ - state?: string; - /** Redirect URI */ - redirectUri?: string; - /** Response type */ - responseType?: string; - /** Nonce */ - nonce?: string; - /** Code challenge */ - codeChallenge?: string; - /** Code challenge method */ - codeChallengeMethod?: string; - /** Error message */ - error?: string; - /** Layout options */ - layout?: Partial; - /** Custom warning message */ - warningMessage?: string; - /** Allow partial scope selection */ - allowScopeSelection?: boolean; - /** Custom approve button text */ - approveText?: string; - /** Custom deny button text */ - denyText?: string; -} - -// ============================================ -// Consent Page Builder -// ============================================ - -/** - * Build an OAuth consent page - */ -export function consentPage(options: ConsentPageOptions): string { - const { - client, - user, - permissions, - approveUrl, - denyUrl, - csrfToken, - state, - redirectUri, - responseType, - nonce, - codeChallenge, - codeChallengeMethod, - error, - layout = {}, - warningMessage, - allowScopeSelection = false, - approveText = 'Allow', - denyText = 'Deny', - } = options; - - // Error alert - const errorAlert = error ? alert(error, { variant: 'danger', dismissible: true }) : ''; - - // Warning for unverified apps - const unverifiedWarning = !client.verified - ? alert(warningMessage || 'This application has not been verified. Only authorize applications you trust.', { - variant: 'warning', - title: 'Unverified Application', - }) - : ''; - - // Client header - const clientHeader = ` -
- ${ - client.icon - ? `${escapeHtml(
-              client.name,
-            )}` - : `
- ${escapeHtml(client.name.charAt(0).toUpperCase())} -
` - } -

- ${ - client.verified - ? ` - ${escapeHtml(client.name)} - - - - ` - : escapeHtml(client.name) - } -

-

wants to access your account

-
- `; - - // User info section - const userSection = user - ? ` -
- ${ - user.avatar - ? `` - : `
- ${escapeHtml((user.name || user.email || 'U').charAt(0).toUpperCase())} -
` - } -
- ${user.name ? `
${escapeHtml(user.name)}
` : ''} - ${user.email ? `
${escapeHtml(user.email)}
` : ''} -
- - Switch account - -
- ` - : ''; - - // Permissions section - const permissionsSection = ` -
-

This will allow ${escapeHtml(client.name)} to:

- ${permissionList(permissions, { - checkable: allowScopeSelection, - inputName: 'scope', - })} -
- `; - - // Hidden form fields - const hiddenFields = [ - csrfToken ? csrfInput(csrfToken) : '', - state ? hiddenInput('state', state) : '', - redirectUri ? hiddenInput('redirect_uri', redirectUri) : '', - responseType ? hiddenInput('response_type', responseType) : '', - nonce ? hiddenInput('nonce', nonce) : '', - codeChallenge ? hiddenInput('code_challenge', codeChallenge) : '', - codeChallengeMethod ? hiddenInput('code_challenge_method', codeChallengeMethod) : '', - hiddenInput('client_id', client.clientId), - // Include all scopes if not selectable - !allowScopeSelection ? permissions.map((p) => hiddenInput('scope[]', p.scope)).join('\n') : '', - ] - .filter(Boolean) - .join('\n'); - - // Action buttons - const actionsHtml = ` -
-
- ${hiddenFields} - - ${outlineButton(denyText, { type: 'submit', fullWidth: true })} -
-
- ${hiddenFields} - - ${primaryButton(approveText, { type: 'submit', fullWidth: true })} -
-
- `; - - // Privacy/Terms links - const linksHtml = - client.privacyUrl || client.termsUrl || client.websiteUrl - ? ` -
- ${ - client.websiteUrl - ? `Website` - : '' - } - ${ - client.privacyUrl - ? `Privacy Policy` - : '' - } - ${ - client.termsUrl - ? `Terms of Service` - : '' - } -
- ` - : ''; - - // Combine all content - const content = ` - ${errorAlert} - ${unverifiedWarning} - ${clientHeader} - ${userSection} - ${permissionsSection} - ${actionsHtml} - ${linksHtml} - `; - - return consentLayout(content, { - title: `Authorize ${client.name}`, - clientName: client.name, - clientIcon: client.icon, - userInfo: user, - ...layout, - }); -} - -// ============================================ -// Consent Success Page -// ============================================ - -/** - * Consent success page options - */ -export interface ConsentSuccessOptions { - /** Client info */ - client: ClientInfo; - /** Redirect URL */ - redirectUrl?: string; - /** Auto redirect delay (ms) */ - autoRedirectDelay?: number; - /** Layout options */ - layout?: Partial; -} - -/** - * Build a consent success page - */ -export function consentSuccessPage(options: ConsentSuccessOptions): string { - const { client, redirectUrl, autoRedirectDelay = 3000, layout = {} } = options; - - const redirectScript = - redirectUrl && autoRedirectDelay > 0 - ? ` - - ` - : ''; - - const content = ` -
-
- - - -
-

Authorization Successful

-

- You have authorized ${escapeHtml(client.name)} to access your account. -

- ${ - redirectUrl - ? `

Redirecting you back to ${escapeHtml(client.name)}...

` - : '' - } -
- ${redirectScript} - `; - - return consentLayout(content, { - title: 'Authorization Successful', - clientName: client.name, - clientIcon: client.icon, - ...layout, - }); -} - -// ============================================ -// Consent Denied Page -// ============================================ - -/** - * Consent denied page options - */ -export interface ConsentDeniedOptions { - /** Client info */ - client: ClientInfo; - /** Redirect URL */ - redirectUrl?: string; - /** Layout options */ - layout?: Partial; -} - -/** - * Build a consent denied page - */ -export function consentDeniedPage(options: ConsentDeniedOptions): string { - const { client, redirectUrl, layout = {} } = options; - - const content = ` -
-
- - - -
-

Authorization Denied

-

- You denied ${escapeHtml(client.name)} access to your account. -

- ${ - redirectUrl - ? ` - - Return to ${escapeHtml(client.name)} - - ` - : '' - } -
- `; - - return consentLayout(content, { - title: 'Authorization Denied', - clientName: client.name, - clientIcon: client.icon, - ...layout, - }); -} diff --git a/libs/ui/src/pages/error.ts b/libs/ui/src/pages/error.ts deleted file mode 100644 index 01e25a22..00000000 --- a/libs/ui/src/pages/error.ts +++ /dev/null @@ -1,358 +0,0 @@ -/** - * Error Page Templates - * - * Various error page templates (404, 500, etc.) - */ - -import { errorLayout, type ErrorLayoutOptions } from '../layouts'; -import { escapeHtml } from '../layouts/base'; - -// ============================================ -// Error Page Types -// ============================================ - -/** - * Error page options - */ -export interface ErrorPageOptions { - /** Error code (404, 500, etc.) */ - code?: string | number; - /** Error title */ - title?: string; - /** Error message */ - message?: string; - /** Detailed error (for development) */ - details?: string; - /** Show stack trace (development only) */ - showStack?: boolean; - /** Stack trace */ - stack?: string; - /** Show retry button */ - showRetry?: boolean; - /** Retry URL */ - retryUrl?: string; - /** Show home button */ - showHome?: boolean; - /** Home URL */ - homeUrl?: string; - /** Custom actions HTML */ - actions?: string; - /** Layout options */ - layout?: Partial; - /** Request ID (for support) */ - requestId?: string; -} - -// ============================================ -// Error Page Builder -// ============================================ - -/** - * Build a generic error page - */ -export function errorPage(options: ErrorPageOptions): string { - const { - code, - title = 'Something went wrong', - message, - details, - showStack = false, - stack, - showRetry = true, - retryUrl, - showHome = true, - homeUrl = '/', - actions, - layout = {}, - requestId, - } = options; - - // Details section (for development) - const detailsHtml = - details || (showStack && stack) - ? ` -
- ${ - details - ? ` -
- Details: -

${escapeHtml(details)}

-
- ` - : '' - } - ${ - showStack && stack - ? ` -
- Stack Trace -
${escapeHtml(stack)}
-
- ` - : '' - } -
- ` - : ''; - - // Request ID for support - const requestIdHtml = requestId - ? ` -

- Request ID: ${escapeHtml(requestId)} -

- ` - : ''; - - // Custom actions or default buttons are handled by errorLayout - const content = ` - ${detailsHtml} - ${actions || ''} - ${requestIdHtml} - `; - - return errorLayout(content, { - title: `${code ? `Error ${code} - ` : ''}${title}`, - errorCode: code?.toString(), - errorTitle: title, - errorMessage: message, - showRetry, - retryUrl, - showHome, - homeUrl, - ...layout, - }); -} - -// ============================================ -// Specific Error Pages -// ============================================ - -/** - * 404 Not Found page - */ -export function notFoundPage(options: Partial = {}): string { - return errorPage({ - code: 404, - title: 'Page Not Found', - message: "The page you're looking for doesn't exist or has been moved.", - showRetry: false, - ...options, - }); -} - -/** - * 403 Forbidden page - */ -export function forbiddenPage(options: Partial = {}): string { - return errorPage({ - code: 403, - title: 'Access Denied', - message: "You don't have permission to access this resource.", - showRetry: false, - ...options, - }); -} - -/** - * 401 Unauthorized page - */ -export function unauthorizedPage(options: Partial & { loginUrl?: string } = {}): string { - const { loginUrl = '/login', ...rest } = options; - - return errorPage({ - code: 401, - title: 'Authentication Required', - message: 'Please sign in to access this resource.', - showRetry: false, - showHome: false, - actions: ` - - `, - ...rest, - }); -} - -/** - * 500 Internal Server Error page - */ -export function serverErrorPage(options: Partial = {}): string { - return errorPage({ - code: 500, - title: 'Server Error', - message: "We're having trouble processing your request. Please try again later.", - showRetry: true, - ...options, - }); -} - -/** - * 503 Service Unavailable page - */ -export function maintenancePage(options: Partial & { estimatedTime?: string } = {}): string { - const { estimatedTime, ...rest } = options; - - const timeMessage = estimatedTime - ? `We expect to be back by ${escapeHtml(estimatedTime)}.` - : "We'll be back shortly."; - - return errorPage({ - code: 503, - title: 'Under Maintenance', - message: `We're currently performing scheduled maintenance. ${timeMessage}`, - showRetry: true, - showHome: false, - ...rest, - }); -} - -/** - * 429 Rate Limit page - */ -export function rateLimitPage(options: Partial & { retryAfter?: number } = {}): string { - const { retryAfter, ...rest } = options; - - const retryMessage = retryAfter - ? `Please wait ${retryAfter} seconds before trying again.` - : 'Please wait a moment before trying again.'; - - return errorPage({ - code: 429, - title: 'Too Many Requests', - message: `You've made too many requests. ${retryMessage}`, - showRetry: true, - showHome: true, - ...rest, - }); -} - -/** - * Offline page - */ -export function offlinePage(options: Partial = {}): string { - return errorPage({ - title: "You're Offline", - message: 'Please check your internet connection and try again.', - showRetry: true, - showHome: false, - ...options, - layout: { - ...options.layout, - }, - }); -} - -/** - * Session expired page - */ -export function sessionExpiredPage(options: Partial & { loginUrl?: string } = {}): string { - const { loginUrl = '/login', ...rest } = options; - - return errorPage({ - title: 'Session Expired', - message: 'Your session has expired. Please sign in again to continue.', - showRetry: false, - showHome: false, - actions: ` - - `, - ...rest, - }); -} - -// ============================================ -// OAuth Error Pages -// ============================================ - -/** - * OAuth error page options - */ -export interface OAuthErrorPageOptions extends Partial { - /** OAuth error code */ - errorCode?: string; - /** OAuth error description */ - errorDescription?: string; - /** Redirect URI */ - redirectUri?: string; - /** Client name */ - clientName?: string; -} - -/** - * OAuth error page - */ -export function oauthErrorPage(options: OAuthErrorPageOptions): string { - const { errorCode, errorDescription, redirectUri, clientName, ...rest } = options; - - // Map OAuth error codes to user-friendly messages - const errorMessages: Record = { - invalid_request: { - title: 'Invalid Request', - message: 'The authorization request is missing required parameters or is malformed.', - }, - unauthorized_client: { - title: 'Unauthorized Client', - message: 'The client is not authorized to request an authorization code.', - }, - access_denied: { - title: 'Access Denied', - message: 'The resource owner denied the authorization request.', - }, - unsupported_response_type: { - title: 'Unsupported Response Type', - message: 'The authorization server does not support the requested response type.', - }, - invalid_scope: { - title: 'Invalid Scope', - message: 'The requested scope is invalid, unknown, or malformed.', - }, - server_error: { - title: 'Server Error', - message: 'The authorization server encountered an unexpected error.', - }, - temporarily_unavailable: { - title: 'Temporarily Unavailable', - message: 'The authorization server is temporarily unavailable. Please try again later.', - }, - }; - - const errorInfo = - errorCode && errorMessages[errorCode] - ? errorMessages[errorCode] - : { title: 'Authorization Error', message: errorDescription || 'An error occurred during authorization.' }; - - const clientMessage = clientName ? ` while connecting to ${escapeHtml(clientName)}` : ''; - - const redirectAction = redirectUri - ? ` - - Return to ${clientName ? escapeHtml(clientName) : 'Application'} - - ` - : ''; - - return errorPage({ - title: errorInfo.title, - message: `${errorInfo.message}${clientMessage}`, - details: errorCode && errorDescription ? `Error: ${errorCode}\n${errorDescription}` : undefined, - showRetry: errorCode === 'server_error' || errorCode === 'temporarily_unavailable', - showHome: true, - actions: redirectAction ? `
${redirectAction}
` : undefined, - ...rest, - }); -} diff --git a/libs/ui/src/pages/index.ts b/libs/ui/src/pages/index.ts deleted file mode 100644 index 089c3e5d..00000000 --- a/libs/ui/src/pages/index.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Page Templates Module - * - * Minimal page templates that complement SDK auth flows. - * Login/Register flows are handled by @frontmcp/sdk auth system. - */ - -// Consent pages (OAuth/OpenID consent flows) -export { - type ClientInfo, - type UserInfo, - type ConsentPageOptions, - type ConsentSuccessOptions, - type ConsentDeniedOptions, - consentPage, - consentSuccessPage, - consentDeniedPage, -} from './consent'; - -// Error pages (generic error display) -export { - type ErrorPageOptions, - type OAuthErrorPageOptions, - errorPage, - notFoundPage, - forbiddenPage, - unauthorizedPage, - serverErrorPage, - maintenancePage, - rateLimitPage, - offlinePage, - sessionExpiredPage, - oauthErrorPage, -} from './error'; diff --git a/libs/ui/src/react/index.ts b/libs/ui/src/react/index.ts index da41fd38..f87504c7 100644 --- a/libs/ui/src/react/index.ts +++ b/libs/ui/src/react/index.ts @@ -111,6 +111,3 @@ export type { AlertProps, AlertRenderProps } from './Alert'; // Legacy web component types (for ref typing) export type { FmcpCard, FmcpBadge, FmcpButton, FmcpAlert, FmcpInput, FmcpSelect } from './types'; - -// Utilities (deprecated, use render/prerender instead) -export { renderChildrenToString, isBrowser, isServer } from './utils'; diff --git a/libs/ui/src/react/utils.ts b/libs/ui/src/react/utils.ts deleted file mode 100644 index b22d5185..00000000 --- a/libs/ui/src/react/utils.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * @file utils.ts - * @description Utility functions for React wrapper components. - * - * Provides helpers for converting React children to HTML strings - * for SSR compatibility with FrontMCP web components. - * - * @module @frontmcp/ui/react/utils - */ - -import type { ReactNode } from 'react'; -import { escapeHtml } from '@frontmcp/uipack/utils'; - -// Lazy-load ReactDOMServer to avoid import errors in non-React environments -let cachedReactDOMServer: typeof import('react-dom/server') | null = null; - -/** - * Get ReactDOMServer lazily (only when needed) - */ -function getReactDOMServer(): typeof import('react-dom/server') | null { - if (!cachedReactDOMServer) { - try { - cachedReactDOMServer = require('react-dom/server'); - } catch { - return null; - } - } - return cachedReactDOMServer; -} - -/** - * Convert React children to an HTML string for SSR. - * - * - Strings are escaped via escapeHtml() - * - Numbers are converted to strings - * - ReactNode is rendered via ReactDOMServer.renderToStaticMarkup() - * - null/undefined return empty string - * - * @param children - React children to convert - * @returns HTML string representation - * - * @example - * ```tsx - * // String children - * renderChildrenToString('Hello') // 'Hello' (escaped) - * - * // React element children - * renderChildrenToString(
Test
) // '
Test
' - * - * // Mixed children - * renderChildrenToString(['Hello', World]) - * ``` - */ -export function renderChildrenToString(children: ReactNode): string { - // Handle null/undefined - if (children == null) { - return ''; - } - - // Handle string - escape HTML - if (typeof children === 'string') { - return escapeHtml(children); - } - - // Handle number - convert to string - if (typeof children === 'number') { - return String(children); - } - - // Handle boolean - React doesn't render true/false - if (typeof children === 'boolean') { - return ''; - } - - // Handle React elements and arrays - use ReactDOMServer - try { - const server = getReactDOMServer(); - if (server) { - return server.renderToStaticMarkup(children as React.ReactElement); - } - // Fallback for non-React environments - return String(children); - } catch { - // Fallback for non-React environments - return String(children); - } -} - -/** - * Check if we're running in a browser environment - */ -export function isBrowser(): boolean { - return typeof window !== 'undefined'; -} - -/** - * Check if we're running in a Node.js environment - */ -export function isServer(): boolean { - return typeof window === 'undefined'; -} diff --git a/libs/ui/src/renderers/index.ts b/libs/ui/src/renderers/index.ts index cc8f54d4..e811a400 100644 --- a/libs/ui/src/renderers/index.ts +++ b/libs/ui/src/renderers/index.ts @@ -38,14 +38,14 @@ export type { } from '@frontmcp/uipack/renderers'; // React Renderer (only available in @frontmcp/ui) -export { ReactRenderer, reactRenderer, buildHydrationScript } from './react.renderer'; +export { ReactRenderer, reactRenderer } from './react.renderer'; // React Renderer Adapter (client-side rendering) export { ReactRendererAdapter, createReactAdapter, loadReactAdapter } from './react.adapter'; // MDX Server Renderer (requires React, uses react-dom/server for SSR) // For client-side CDN-based MDX rendering, use MdxClientRenderer from @frontmcp/uipack -export { MdxRenderer, mdxRenderer, buildMdxHydrationScript } from './mdx.renderer'; +export { MdxRenderer, mdxRenderer } from './mdx.renderer'; // JSX Execution (requires React) // For transpilation only (without React), use transpileJsx from @frontmcp/uipack diff --git a/libs/ui/src/renderers/react.renderer.ts b/libs/ui/src/renderers/react.renderer.ts index fbdd4f17..e4b6529c 100644 --- a/libs/ui/src/renderers/react.renderer.ts +++ b/libs/ui/src/renderers/react.renderer.ts @@ -5,7 +5,15 @@ * - Imported React components (already transpiled) * - JSX string templates (transpiled at runtime with SWC) * - * Uses react-dom/server for SSR to HTML. + * Generates HTML for CLIENT-SIDE rendering (no SSR). + * React components are rendered in the browser, not on the server. + * + * @example + * ```typescript + * // The generated HTML includes React CDN scripts and a render script + * // The component is rendered client-side when the page loads + * const html = await reactRenderer.render(MyWidget, context); + * ``` */ import type { TemplateContext } from '@frontmcp/uipack/runtime'; @@ -18,14 +26,48 @@ import type { RuntimeScripts, ToolUIProps, } from '@frontmcp/uipack/renderers'; -import { - isReactComponent, - containsJsx, - hashString, - transpileJsx, - executeTranspiledCode, - transpileCache, -} from '@frontmcp/uipack/renderers'; +import { isReactComponent, containsJsx, hashString, transpileJsx } from '@frontmcp/uipack/renderers'; + +// ============================================ +// Component Name Validation +// ============================================ + +/** + * Valid JavaScript identifier pattern. + * Matches only alphanumeric characters, underscores, and dollar signs, + * and must not start with a digit. + */ +const VALID_JS_IDENTIFIER = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/; + +/** + * Validate that a component name is a safe JavaScript identifier. + * + * Prevents code injection attacks where a malicious function name + * could break out of the identifier context. + * + * @param name - Component name to validate + * @returns True if the name is a valid JavaScript identifier + */ +function isValidComponentName(name: string): boolean { + return VALID_JS_IDENTIFIER.test(name); +} + +/** + * Sanitize a component name for safe use in generated JavaScript code. + * + * If the name is not a valid identifier, returns a safe fallback. + * + * @param name - Component name to sanitize + * @returns Safe component name + */ +function sanitizeComponentName(name: string): string { + if (isValidComponentName(name)) { + return name; + } + // Replace invalid characters with underscores, ensure it starts correctly + const sanitized = name.replace(/[^a-zA-Z0-9_$]/g, '_').replace(/^[0-9]/, '_$&'); + return sanitized || 'Component'; +} /** * Types this renderer can handle. @@ -60,7 +102,8 @@ console.warn('[FrontMCP] React hydration not available on this platform.'); * - Imported React components (FC or class) * - JSX string templates (transpiled with SWC at runtime) * - * Renders to HTML using react-dom/server's renderToString. + * Generates HTML for CLIENT-SIDE rendering. The component is rendered + * in the browser using React from CDN, not server-side rendered. * * @example Imported component * ```typescript @@ -88,14 +131,6 @@ export class ReactRenderer implements UIRenderer { readonly type = 'react' as const; readonly priority = 20; // Higher priority than HTML - /** - * Lazy-loaded React modules. - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private React: any = null; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private ReactDOMServer: any = null; - /** * Check if this renderer can handle the given template. * @@ -146,76 +181,137 @@ export class ReactRenderer implements UIRenderer { } /** - * Render the template to HTML string using react-dom/server. + * Render the template to HTML for client-side rendering. + * + * Unlike SSR, this method generates HTML that will be rendered + * client-side by React in the browser. No server-side React required. + * + * The generated HTML includes: + * - A container div for the React root + * - The component code (transpiled if needed) + * - Props embedded as a data attribute + * - A render script that initializes the component */ async render( template: ReactTemplate, context: TemplateContext, - options?: RenderOptions, + _options?: RenderOptions, ): Promise { - // Ensure React is loaded - await this.loadReact(); + // Build props from context + const props: ToolUIProps = { + input: context.input, + output: context.output, + structuredContent: context.structuredContent, + helpers: context.helpers, + }; + + // Escape props for HTML embedding + const escapedProps = JSON.stringify(props) + .replace(/&/g, '&') + .replace(/'/g, ''') + .replace(//g, '>'); + + // Generate unique ID for this render + const rootId = `frontmcp-react-${hashString(Date.now().toString()).slice(0, 8)}`; - // Get the component function - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let Component: (props: ToolUIProps) => any; + // Get the component code + let componentCode: string; + let componentName: string; if (typeof template === 'function') { - // Already a component function - Component = template; + // For imported components, we need the component to be registered + // Sanitize the component name to prevent code injection attacks + const rawName = (template as { name?: string }).name || 'Component'; + componentName = sanitizeComponentName(rawName); + + // Cache the component function for client-side access + componentCode = ` + // Component should be registered via window.__frontmcp_components['${componentName}'] + (function() { + if (!window.__frontmcp_components || !window.__frontmcp_components['${componentName}']) { + console.error('[FrontMCP] Component "${componentName}" not registered. Use buildHydrationScript() to register components.'); + } + })(); + `; } else if (typeof template === 'string') { - // Transpile and execute the JSX string + // Transpile JSX string to JavaScript const transpiled = await this.transpile(template); - // Check cache for the executed component - const cached = transpileCache.getByKey(`exec:${transpiled.hash}`); - if (cached) { - Component = cached.code as unknown as typeof Component; - } else { - // Execute the transpiled code to get the component - Component = await executeTranspiledCode(transpiled.code, { - // Provide any additional MDX components if specified - ...options?.mdxComponents, - }); - - // Cache the component function - transpileCache.setByKey(`exec:${transpiled.hash}`, { - code: Component as unknown as string, - hash: transpiled.hash, - cached: false, - }); - } + // Extract component name from transpiled code + // The regex only matches valid identifiers, so this is already safe + const match = transpiled.code.match(/function\s+(\w+)/); + const rawName = match?.[1] || 'Widget'; + componentName = sanitizeComponentName(rawName); + + componentCode = transpiled.code; } else { throw new Error('Invalid template type for ReactRenderer'); } - // Build props from context - const props: ToolUIProps = { - input: context.input, - output: context.output, - structuredContent: context.structuredContent, - helpers: context.helpers, + // Generate HTML with client-side rendering script + const html = ` +
+
+ + + + + Loading... +
+
+ +`; - return html; + return html.trim(); } /** @@ -240,22 +336,6 @@ export class ReactRenderer implements UIRenderer { isInline: false, }; } - - /** - * Load React and ReactDOMServer modules. - */ - private async loadReact(): Promise { - if (this.React && this.ReactDOMServer) { - return; - } - - try { - this.React = await import('react'); - this.ReactDOMServer = await import('react-dom/server'); - } catch { - throw new Error('React is required for ReactRenderer. Install react and react-dom: npm install react react-dom'); - } - } } /** diff --git a/libs/ui/src/widgets/index.ts b/libs/ui/src/widgets/index.ts deleted file mode 100644 index 3ea3c631..00000000 --- a/libs/ui/src/widgets/index.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Widgets Module - * - * Specialized widgets for OpenAI App SDK and common UI patterns. - */ - -// Resource widgets -export { - type ResourceType, - type ResourceMeta, - type ResourceAction, - type ResourceOptions, - type ResourceListOptions, - type CodePreviewOptions, - type ImagePreviewOptions, - resourceWidget, - resourceList, - resourceItem, - codePreview, - imagePreview, -} from './resource'; - -// Progress widgets -export { - type ProgressBarOptions, - type Step, - type StepProgressOptions, - type CircularProgressOptions, - type StatusIndicatorOptions, - type SkeletonOptions, - progressBar, - stepProgress, - circularProgress, - statusIndicator, - skeleton, - contentSkeleton, -} from './progress'; diff --git a/libs/ui/src/widgets/progress.ts b/libs/ui/src/widgets/progress.ts deleted file mode 100644 index a15ec950..00000000 --- a/libs/ui/src/widgets/progress.ts +++ /dev/null @@ -1,501 +0,0 @@ -/** - * Progress and Status Widgets - * - * Components for displaying progress, loading states, and status information. - */ - -import { escapeHtml } from '../layouts/base'; - -// ============================================ -// Progress Bar -// ============================================ - -/** - * Progress bar options - */ -export interface ProgressBarOptions { - /** Progress value (0-100) */ - value: number; - /** Show percentage text */ - showLabel?: boolean; - /** Label position */ - labelPosition?: 'inside' | 'outside' | 'none'; - /** Size */ - size?: 'sm' | 'md' | 'lg'; - /** Color variant */ - variant?: 'primary' | 'success' | 'warning' | 'danger' | 'info'; - /** Animated (striped) */ - animated?: boolean; - /** Additional CSS classes */ - className?: string; - /** Custom label */ - label?: string; -} - -/** - * Build a progress bar - */ -export function progressBar(options: ProgressBarOptions): string { - const { - value, - showLabel = true, - labelPosition = 'outside', - size = 'md', - variant = 'primary', - animated = false, - className = '', - label, - } = options; - - const clampedValue = Math.min(100, Math.max(0, value)); - - const sizeClasses: Record = { - sm: 'h-1.5', - md: 'h-2.5', - lg: 'h-4', - }; - - const variantClasses: Record = { - primary: 'bg-primary', - success: 'bg-success', - warning: 'bg-warning', - danger: 'bg-danger', - info: 'bg-blue-500', - }; - - const animatedClass = animated ? 'bg-stripes animate-stripes' : ''; - - const displayLabel = label || `${Math.round(clampedValue)}%`; - - const insideLabel = - labelPosition === 'inside' && size === 'lg' && clampedValue > 10 - ? `${escapeHtml( - displayLabel, - )}` - : ''; - - const outsideLabel = - showLabel && labelPosition === 'outside' - ? `
- ${label ? escapeHtml(label) : 'Progress'} - ${Math.round(clampedValue)}% -
` - : ''; - - return `
- ${outsideLabel} -
-
- ${insideLabel} -
-
- ${ - animated - ? `` - : '' - }`; -} - -// ============================================ -// Multi-Step Progress -// ============================================ - -/** - * Step definition - */ -export interface Step { - /** Step label */ - label: string; - /** Step description */ - description?: string; - /** Step status */ - status: 'completed' | 'current' | 'upcoming'; - /** Icon (optional) */ - icon?: string; - /** URL for clickable steps */ - href?: string; -} - -/** - * Multi-step progress options - */ -export interface StepProgressOptions { - /** Steps */ - steps: Step[]; - /** Orientation */ - orientation?: 'horizontal' | 'vertical'; - /** Connector style */ - connector?: 'line' | 'dashed' | 'none'; - /** Additional CSS classes */ - className?: string; -} - -/** - * Build a multi-step progress indicator - */ -export function stepProgress(options: StepProgressOptions): string { - const { steps, orientation = 'horizontal', connector = 'line', className = '' } = options; - - const getStepIcon = (step: Step, index: number): string => { - if (step.icon) return step.icon; - - if (step.status === 'completed') { - return ` - - `; - } - - return `${index + 1}`; - }; - - const getStepClasses = (status: Step['status']): { circle: string; text: string } => { - switch (status) { - case 'completed': - return { - circle: 'bg-success text-white', - text: 'text-text-primary', - }; - case 'current': - return { - circle: 'bg-primary text-white ring-4 ring-primary/20', - text: 'text-primary font-medium', - }; - case 'upcoming': - default: - return { - circle: 'bg-gray-200 text-gray-500', - text: 'text-text-secondary', - }; - } - }; - - if (orientation === 'vertical') { - const stepsHtml = steps - .map((step, index) => { - const classes = getStepClasses(step.status); - const isLast = index === steps.length - 1; - - const connectorHtml = - !isLast && connector !== 'none' - ? `
` - : ''; - - const stepContent = ` -
-
- ${getStepIcon(step, index)} -
-
-
${escapeHtml(step.label)}
- ${ - step.description - ? `

${escapeHtml(step.description)}

` - : '' - } -
-
- ${connectorHtml} - `; - - return step.href && step.status === 'completed' - ? `${stepContent}` - : `
${stepContent}
`; - }) - .join('\n'); - - return `
${stepsHtml}
`; - } - - // Horizontal orientation - const stepsHtml = steps - .map((step, index) => { - const classes = getStepClasses(step.status); - const isLast = index === steps.length - 1; - - const connectorHtml = - !isLast && connector !== 'none' - ? `
` - : ''; - - const stepHtml = ` -
-
- ${getStepIcon(step, index)} -
-
-
${escapeHtml(step.label)}
- ${ - step.description - ? `

${escapeHtml(step.description)}

` - : '' - } -
-
- `; - - const clickableStep = - step.href && step.status === 'completed' - ? `${stepHtml}` - : stepHtml; - - return `${clickableStep}${connectorHtml}`; - }) - .join('\n'); - - return `
${stepsHtml}
`; -} - -// ============================================ -// Circular Progress -// ============================================ - -/** - * Circular progress options - */ -export interface CircularProgressOptions { - /** Progress value (0-100) */ - value: number; - /** Size in pixels */ - size?: number; - /** Stroke width */ - strokeWidth?: number; - /** Color variant */ - variant?: 'primary' | 'success' | 'warning' | 'danger'; - /** Show percentage */ - showLabel?: boolean; - /** Custom label */ - label?: string; - /** Additional CSS classes */ - className?: string; -} - -/** - * Build a circular progress indicator - */ -export function circularProgress(options: CircularProgressOptions): string { - const { value, size = 80, strokeWidth = 8, variant = 'primary', showLabel = true, label, className = '' } = options; - - const clampedValue = Math.min(100, Math.max(0, value)); - const radius = (size - strokeWidth) / 2; - const circumference = radius * 2 * Math.PI; - const offset = circumference - (clampedValue / 100) * circumference; - - const variantColors: Record = { - primary: 'text-primary', - success: 'text-success', - warning: 'text-warning', - danger: 'text-danger', - }; - - const displayLabel = label || `${Math.round(clampedValue)}%`; - - return `
- - - - - - - ${ - showLabel - ? `${escapeHtml(displayLabel)}` - : '' - } -
`; -} - -// ============================================ -// Status Indicator -// ============================================ - -/** - * Status indicator options - */ -export interface StatusIndicatorOptions { - /** Status state */ - status: 'online' | 'offline' | 'busy' | 'away' | 'loading' | 'error' | 'success'; - /** Status label */ - label?: string; - /** Size */ - size?: 'sm' | 'md' | 'lg'; - /** Show pulse animation */ - pulse?: boolean; - /** Additional CSS classes */ - className?: string; -} - -/** - * Build a status indicator - */ -export function statusIndicator(options: StatusIndicatorOptions): string { - const { status, label, size = 'md', pulse = false, className = '' } = options; - - const sizeClasses: Record = { - sm: { dot: 'w-2 h-2', text: 'text-xs' }, - md: { dot: 'w-2.5 h-2.5', text: 'text-sm' }, - lg: { dot: 'w-3 h-3', text: 'text-base' }, - }; - - const statusClasses: Record = { - online: { color: 'bg-success', label: 'Online' }, - offline: { color: 'bg-gray-400', label: 'Offline' }, - busy: { color: 'bg-danger', label: 'Busy' }, - away: { color: 'bg-warning', label: 'Away' }, - loading: { color: 'bg-blue-500', label: 'Loading' }, - error: { color: 'bg-danger', label: 'Error' }, - success: { color: 'bg-success', label: 'Success' }, - }; - - const statusInfo = statusClasses[status]; - const sizeInfo = sizeClasses[size]; - const displayLabel = label || statusInfo.label; - - const pulseHtml = - pulse || status === 'loading' - ? `` - : ''; - - return `
- - ${pulseHtml} - - - ${displayLabel ? `${escapeHtml(displayLabel)}` : ''} -
`; -} - -// ============================================ -// Skeleton Loader -// ============================================ - -/** - * Skeleton loader options - */ -export interface SkeletonOptions { - /** Skeleton type */ - type?: 'text' | 'circle' | 'rect' | 'card'; - /** Width (CSS value) */ - width?: string; - /** Height (CSS value) */ - height?: string; - /** Number of text lines */ - lines?: number; - /** Animated */ - animated?: boolean; - /** Additional CSS classes */ - className?: string; -} - -/** - * Build a skeleton loader - */ -export function skeleton(options: SkeletonOptions = {}): string { - const { type = 'text', width, height, lines = 3, animated = true, className = '' } = options; - - const animateClass = animated ? 'animate-pulse' : ''; - const baseClass = `bg-gray-200 ${animateClass}`; - - switch (type) { - case 'circle': - return `
`; - - case 'rect': - return `
`; - - case 'card': - return `
-
-
-
-
-
-
`; - - case 'text': - default: { - const linesHtml = Array(lines) - .fill(0) - .map((_, i) => { - const lineWidth = i === lines - 1 ? '60%' : i === 0 ? '90%' : '80%'; - return `
`; - }) - .join('\n'); - - return `
- ${linesHtml} -
`; - } - } -} - -/** - * Build a content skeleton with avatar and text - */ -export function contentSkeleton(options: { animated?: boolean; className?: string } = {}): string { - const { animated = true, className = '' } = options; - const animateClass = animated ? 'animate-pulse' : ''; - - return `
-
-
-
-
-
-
`; -} diff --git a/libs/ui/src/widgets/resource.ts b/libs/ui/src/widgets/resource.ts deleted file mode 100644 index 30ff96ae..00000000 --- a/libs/ui/src/widgets/resource.ts +++ /dev/null @@ -1,572 +0,0 @@ -/** - * OpenAI App SDK Resource Widgets - * - * Components for displaying resources in OpenAI's App SDK format. - * These widgets are designed to work with OpenAI Canvas and similar interfaces. - */ - -import { escapeHtml } from '../layouts/base'; -import { card, type CardOptions } from '../components/card'; -import { badge, type BadgeVariant } from '../components/badge'; -import { button } from '../components/button'; - -// ============================================ -// Resource Types -// ============================================ - -/** - * Resource type identifiers - */ -export type ResourceType = - | 'document' - | 'image' - | 'code' - | 'data' - | 'file' - | 'link' - | 'user' - | 'event' - | 'message' - | 'task' - | 'custom'; - -/** - * Resource metadata - */ -export interface ResourceMeta { - /** Creation timestamp */ - createdAt?: string | Date; - /** Last modified timestamp */ - updatedAt?: string | Date; - /** Author/creator */ - author?: string; - /** File size (bytes) */ - size?: number; - /** MIME type */ - mimeType?: string; - /** Tags */ - tags?: string[]; - /** Custom metadata */ - [key: string]: unknown; -} - -/** - * Resource action - */ -export interface ResourceAction { - /** Action label */ - label: string; - /** Action icon */ - icon?: string; - /** Action URL */ - href?: string; - /** HTMX attributes */ - htmx?: { - get?: string; - post?: string; - delete?: string; - target?: string; - swap?: string; - confirm?: string; - }; - /** Variant */ - variant?: 'primary' | 'secondary' | 'danger' | 'ghost'; - /** Disabled */ - disabled?: boolean; -} - -/** - * Base resource options - */ -export interface ResourceOptions { - /** Resource type */ - type: ResourceType; - /** Resource title */ - title: string; - /** Resource description */ - description?: string; - /** Resource icon */ - icon?: string; - /** Resource thumbnail URL */ - thumbnail?: string; - /** Resource URL */ - url?: string; - /** Resource metadata */ - meta?: ResourceMeta; - /** Resource status */ - status?: { - label: string; - variant: BadgeVariant; - }; - /** Available actions */ - actions?: ResourceAction[]; - /** Additional CSS classes */ - className?: string; - /** Card options */ - cardOptions?: Partial; -} - -// ============================================ -// Resource Icons -// ============================================ - -const resourceIcons: Record = { - document: ` - - `, - image: ` - - `, - code: ` - - `, - data: ` - - `, - file: ` - - `, - link: ` - - `, - user: ` - - `, - event: ` - - `, - message: ` - - `, - task: ` - - `, - custom: ` - - `, -}; - -// ============================================ -// Utility Functions -// ============================================ - -/** - * Format file size - */ -function formatFileSize(bytes: number): string { - if (bytes === 0) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; -} - -/** - * Format date - */ -function formatDate(date: string | Date): string { - const d = typeof date === 'string' ? new Date(date) : date; - return d.toLocaleDateString('en-US', { - year: 'numeric', - month: 'short', - day: 'numeric', - }); -} - -// ============================================ -// Resource Widget Builder -// ============================================ - -/** - * Build a resource widget - */ -export function resourceWidget(options: ResourceOptions): string { - const { - type, - title, - description, - icon, - thumbnail, - url, - meta, - status, - actions = [], - className = '', - cardOptions = {}, - } = options; - - // Icon/Thumbnail - const iconHtml = thumbnail - ? `
- ${escapeHtml(title)} -
` - : `
- ${icon || resourceIcons[type]} -
`; - - // Status badge - const statusHtml = status ? badge(status.label, { variant: status.variant, size: 'sm' }) : ''; - - // Metadata - const metaItems: string[] = []; - if (meta?.size) { - metaItems.push(formatFileSize(meta.size)); - } - if (meta?.mimeType) { - metaItems.push(meta.mimeType); - } - if (meta?.updatedAt) { - metaItems.push(`Updated ${formatDate(meta.updatedAt)}`); - } else if (meta?.createdAt) { - metaItems.push(`Created ${formatDate(meta.createdAt)}`); - } - if (meta?.author) { - metaItems.push(`by ${meta.author}`); - } - - const metaHtml = - metaItems.length > 0 ? `
${metaItems.join(' • ')}
` : ''; - - // Tags - const tagsHtml = - meta?.tags && meta.tags.length > 0 - ? `
- ${meta.tags.map((tag) => badge(tag, { variant: 'default', size: 'sm' })).join('')} -
` - : ''; - - // Actions - const actionsHtml = - actions.length > 0 - ? `
- ${actions - .map((action) => { - const variantMap: Record = { - primary: 'primary', - secondary: 'secondary', - danger: 'danger', - ghost: 'ghost', - }; - const variant = action.variant ? variantMap[action.variant] : 'ghost'; - - const htmxAttrs: string[] = []; - if (action.htmx) { - if (action.htmx.get) htmxAttrs.push(`hx-get="${escapeHtml(action.htmx.get)}"`); - if (action.htmx.post) htmxAttrs.push(`hx-post="${escapeHtml(action.htmx.post)}"`); - if (action.htmx.delete) htmxAttrs.push(`hx-delete="${escapeHtml(action.htmx.delete)}"`); - if (action.htmx.target) htmxAttrs.push(`hx-target="${escapeHtml(action.htmx.target)}"`); - if (action.htmx.swap) htmxAttrs.push(`hx-swap="${escapeHtml(action.htmx.swap)}"`); - if (action.htmx.confirm) htmxAttrs.push(`hx-confirm="${escapeHtml(action.htmx.confirm)}"`); - } - - return button(action.label, { - variant: variant as 'primary' | 'secondary' | 'danger' | 'ghost', - size: 'sm', - href: action.href, - disabled: action.disabled, - iconBefore: action.icon, - }); - }) - .join('')} -
` - : ''; - - // Content - const content = ` -
- ${iconHtml} -
-
-
- ${ - url - ? `${escapeHtml( - title, - )}` - : `

${escapeHtml(title)}

` - } - ${ - description - ? `

${escapeHtml(description)}

` - : '' - } - ${metaHtml} - ${tagsHtml} -
- ${statusHtml} -
- ${actionsHtml} -
-
- `; - - return card(content, { - variant: 'default', - size: 'md', - className: `resource-widget resource-${type} ${className}`, - ...cardOptions, - }); -} - -// ============================================ -// Resource List Widget -// ============================================ - -/** - * Resource list options - */ -export interface ResourceListOptions { - /** Resources to display */ - resources: ResourceOptions[]; - /** List title */ - title?: string; - /** Empty state message */ - emptyMessage?: string; - /** Grid or list layout */ - layout?: 'list' | 'grid'; - /** Grid columns */ - columns?: 1 | 2 | 3 | 4; - /** Additional CSS classes */ - className?: string; - /** Show load more button */ - showLoadMore?: boolean; - /** Load more URL */ - loadMoreUrl?: string; -} - -/** - * Build a resource list widget - */ -export function resourceList(options: ResourceListOptions): string { - const { - resources, - title, - emptyMessage = 'No resources found', - layout = 'list', - columns = 2, - className = '', - showLoadMore = false, - loadMoreUrl, - } = options; - - // Title - const titleHtml = title ? `

${escapeHtml(title)}

` : ''; - - // Empty state - if (resources.length === 0) { - return `
- ${titleHtml} -
- - - -

${escapeHtml(emptyMessage)}

-
-
`; - } - - // Layout classes - const layoutClasses = layout === 'grid' ? `grid grid-cols-1 md:grid-cols-${columns} gap-4` : 'space-y-4'; - - // Resources - const resourcesHtml = resources.map((r) => resourceWidget(r)).join('\n'); - - // Load more button - const loadMoreHtml = - showLoadMore && loadMoreUrl - ? `
- ${button('Load More', { - variant: 'outline', - href: loadMoreUrl, - })} -
` - : ''; - - return `
- ${titleHtml} -
- ${resourcesHtml} -
- ${loadMoreHtml} -
`; -} - -// ============================================ -// Compact Resource Item -// ============================================ - -/** - * Build a compact resource item (for inline display) - */ -export function resourceItem(options: Omit): string { - const { type, title, description, icon, url, meta, status } = options; - - const iconHtml = `
- ${icon || resourceIcons[type]} -
`; - - const statusHtml = status ? badge(status.label, { variant: status.variant, size: 'sm' }) : ''; - - const metaText = meta?.size ? formatFileSize(meta.size) : ''; - - const titleElement = url - ? `${escapeHtml(title)}` - : `${escapeHtml(title)}`; - - return `
- ${iconHtml} -
-
- ${titleElement} - ${statusHtml} -
- ${ - description || metaText - ? `

${escapeHtml(description || metaText)}

` - : '' - } -
-
`; -} - -// ============================================ -// Preview Widget -// ============================================ - -/** - * Code preview options - */ -export interface CodePreviewOptions { - /** Code content */ - code: string; - /** Language */ - language?: string; - /** Filename */ - filename?: string; - /** Show line numbers */ - lineNumbers?: boolean; - /** Max height */ - maxHeight?: string; - /** Copy button */ - showCopy?: boolean; - /** Additional CSS classes */ - className?: string; -} - -/** - * Build a code preview widget - */ -export function codePreview(options: CodePreviewOptions): string { - const { - code, - language = 'text', - filename, - lineNumbers = true, - maxHeight = '400px', - showCopy = true, - className = '', - } = options; - - const lines = code.split('\n'); - - const lineNumbersHtml = lineNumbers - ? `
- ${lines.map((_, i) => `
${i + 1}
`).join('')} -
` - : ''; - - const copyScript = showCopy - ? `` - : ''; - - const copyButton = showCopy - ? `` - : ''; - - return `
- ${ - filename || showCopy - ? ` -
- ${filename ? `${escapeHtml(filename)}` : ''} -
- ${language ? `${escapeHtml(language)}` : ''} - ${copyButton} -
-
- ` - : '' - } -
-
- ${lineNumbersHtml} -
${escapeHtml(code)}
-
-
- ${copyScript} -
`; -} - -/** - * Image preview options - */ -export interface ImagePreviewOptions { - /** Image URL */ - src: string; - /** Alt text */ - alt: string; - /** Caption */ - caption?: string; - /** Max height */ - maxHeight?: string; - /** Clickable (opens in new tab) */ - clickable?: boolean; - /** Additional CSS classes */ - className?: string; -} - -/** - * Build an image preview widget - */ -export function imagePreview(options: ImagePreviewOptions): string { - const { src, alt, caption, maxHeight = '400px', clickable = true, className = '' } = options; - - const imageHtml = `${escapeHtml(alt)}`; - - const captionHtml = caption - ? `

${escapeHtml(caption)}

` - : ''; - - const content = clickable - ? `${imageHtml}` - : imageHtml; - - return `
- ${content} - ${captionHtml} -
`; -} diff --git a/libs/uipack/CLAUDE.md b/libs/uipack/CLAUDE.md index c799450e..f90c1e17 100644 --- a/libs/uipack/CLAUDE.md +++ b/libs/uipack/CLAUDE.md @@ -2,17 +2,17 @@ ## Overview -`@frontmcp/uipack` provides **bundling, build tools, HTML components, and platform adapters** for MCP UI development - all without requiring React. +`@frontmcp/uipack` provides **bundling, build tools, platform adapters, and theming** for MCP UI development - all without requiring React. This is the React-free core package. For React components and hooks, use `@frontmcp/ui`. **Key Principles:** - Zero React dependency -- Pure HTML string generation -- Zod schema validation for all component inputs - Platform-aware theming and CDN configuration -- HTMX support for dynamic interactions +- Build tools for Tool UI generation +- esbuild/SWC bundling utilities +- Zod schema validation ## Architecture @@ -20,17 +20,15 @@ This is the React-free core package. For React components and hooks, use `@front libs/uipack/src/ ├── adapters/ # Platform adapters (OpenAI, Claude, etc.) ├── base-template/ # Default HTML wrappers with polyfills -├── bridge/ # Multi-platform MCP bridge +├── bridge-runtime/ # MCP bridge runtime generation ├── build/ # Build-time API (buildToolUI, etc.) -├── bundler/ # esbuild/SWC bundling -├── components/ # HTML string components (button, card, etc.) +├── bundler/ # esbuild/SWC bundling, caching, sandbox ├── dependency/ # CDN resolution and import maps ├── handlebars/ # Handlebars template engine -├── layouts/ # Page layout templates -├── pages/ # Pre-built pages (consent, error) +├── preview/ # Preview server utilities ├── registry/ # Tool UI registry -├── renderers/ # HTML/MDX renderers -├── runtime/ # Runtime utilities (template helpers) +├── renderers/ # HTML/MDX client renderers +├── runtime/ # Runtime utilities (wrapper, sanitizer, CSP) ├── styles/ # Style variant definitions ├── theme/ # Theme system and CDN config ├── tool-template/ # Tool template utilities @@ -38,103 +36,52 @@ libs/uipack/src/ ├── typings/ # .d.ts type fetching ├── utils/ # Utilities (escapeHtml, safeStringify) ├── validation/ # Zod validation utilities -├── web-components/ # Custom HTML elements -├── widgets/ # OpenAI widget utilities └── index.ts # Main barrel exports ``` ## Package Split -| Package | Purpose | React Required | -| ------------------ | --------------------------------------------------------- | -------------- | -| `@frontmcp/uipack` | Bundling, build tools, HTML components, platform adapters | No | -| `@frontmcp/ui` | React components, hooks, SSR rendering | Yes | +| Package | Purpose | React Required | +| ------------------ | ------------------------------------------------------- | -------------- | +| `@frontmcp/uipack` | Bundling, build tools, platform adapters, theme | No | +| `@frontmcp/ui` | React components, hooks, SSR rendering, HTML components | Yes | ## Entry Points -| Path | Purpose | -| --------------------------------- | ------------------------------------- | -| `@frontmcp/uipack` | Main exports | -| `@frontmcp/uipack/adapters` | Platform adapters and meta builders | -| `@frontmcp/uipack/base-template` | Default HTML templates with polyfills | -| `@frontmcp/uipack/bridge` | MCP bridge for multiple platforms | -| `@frontmcp/uipack/build` | Build-time API | -| `@frontmcp/uipack/bundler` | esbuild/SWC bundling | -| `@frontmcp/uipack/components` | HTML string components | -| `@frontmcp/uipack/dependency` | CDN resolution | -| `@frontmcp/uipack/handlebars` | Handlebars integration | -| `@frontmcp/uipack/layouts` | Page layouts | -| `@frontmcp/uipack/pages` | Pre-built pages | -| `@frontmcp/uipack/registry` | Tool UI registry | -| `@frontmcp/uipack/renderers` | HTML/MDX renderers | -| `@frontmcp/uipack/runtime` | Runtime utilities | -| `@frontmcp/uipack/styles` | Style variants | -| `@frontmcp/uipack/theme` | Theme system | -| `@frontmcp/uipack/types` | Type definitions | -| `@frontmcp/uipack/utils` | Utilities | -| `@frontmcp/uipack/validation` | Zod validation | -| `@frontmcp/uipack/web-components` | Custom elements | -| `@frontmcp/uipack/widgets` | OpenAI widgets | - -## Component Development - -### 1. Create Schema First - -Every component must have a Zod schema with `.strict()` mode: +| Path | Purpose | +| -------------------------------- | ------------------------------------- | +| `@frontmcp/uipack` | Main exports | +| `@frontmcp/uipack/adapters` | Platform adapters and meta builders | +| `@frontmcp/uipack/base-template` | Default HTML templates with polyfills | +| `@frontmcp/uipack/build` | Build-time API (buildToolUI) | +| `@frontmcp/uipack/bundler` | esbuild/SWC bundling, cache, sandbox | +| `@frontmcp/uipack/dependency` | CDN resolution and import maps | +| `@frontmcp/uipack/handlebars` | Handlebars integration | +| `@frontmcp/uipack/preview` | Preview server utilities | +| `@frontmcp/uipack/registry` | Tool UI registry | +| `@frontmcp/uipack/renderers` | HTML/MDX client renderers | +| `@frontmcp/uipack/runtime` | Runtime utilities (wrapper, CSP) | +| `@frontmcp/uipack/styles` | Style variants | +| `@frontmcp/uipack/theme` | Theme system and platform config | +| `@frontmcp/uipack/types` | Type definitions | +| `@frontmcp/uipack/typings` | TypeScript definition fetching | +| `@frontmcp/uipack/utils` | Utilities | +| `@frontmcp/uipack/validation` | Zod validation | -```typescript -// component.schema.ts -import { z } from 'zod'; - -export const ComponentOptionsSchema = z - .object({ - variant: z.enum(['primary', 'secondary']).optional(), - size: z.enum(['sm', 'md', 'lg']).optional(), - disabled: z.boolean().optional(), - className: z.string().optional(), - htmx: z - .object({ - get: z.string().optional(), - post: z.string().optional(), - target: z.string().optional(), - swap: z.string().optional(), - }) - .strict() - .optional(), - }) - .strict(); // IMPORTANT: Reject unknown properties - -export type ComponentOptions = z.infer; -``` - -### 2. Validate Inputs in Component +## Build API ```typescript -// component.ts -import { validateOptions } from '../validation'; -import { ComponentOptionsSchema, type ComponentOptions } from './component.schema'; - -export function component(content: string, options: ComponentOptions = {}): string { - const validation = validateOptions(options, { - schema: ComponentOptionsSchema, - componentName: 'component', - }); - - if (!validation.success) { - return validation.error; // Returns styled error box HTML - } - - const { variant = 'primary', size = 'md' } = validation.data; - return `
${escapeHtml(content)}
`; -} -``` - -### 3. Always Escape User Content +import { buildToolUI, getOutputModeForClient } from '@frontmcp/uipack/build'; -```typescript -import { escapeHtml } from '../utils'; +// Build tool UI HTML +const html = await buildToolUI({ + template: '
{{output.data}}
', + context: { input: {}, output: { data: 'Hello' } }, + platform: 'openai', +}); -const html = `
${escapeHtml(content)}
`; +// Get output mode for client +const mode = getOutputModeForClient('openai'); ``` ## Theme System @@ -190,20 +137,56 @@ const scripts = buildCdnScriptsFromTheme(DEFAULT_THEME, { inline: false }); const inlineScripts = buildCdnScriptsFromTheme(DEFAULT_THEME, { inline: true }); ``` -## Build API +## Renderers + +### HTML Renderer ```typescript -import { buildToolUI, getOutputModeForClient } from '@frontmcp/uipack/build'; +import { htmlRenderer, HtmlRenderer } from '@frontmcp/uipack/renderers'; -// Build tool UI HTML -const html = await buildToolUI({ - template: '
{{output.data}}
', - context: { input: {}, output: { data: 'Hello' } }, - platform: 'openai', +// Render HTML template +const html = await htmlRenderer.render(template, context); +``` + +### MDX Client Renderer (CDN-based) + +```typescript +import { mdxClientRenderer, MdxClientRenderer } from '@frontmcp/uipack/renderers'; + +// Render MDX using CDN-based React (no bundled React) +const html = await mdxClientRenderer.render(mdxContent, context); +``` + +> **Note:** For server-side MDX rendering with bundled React, use `@frontmcp/ui/renderers`. + +## Bundler Utilities + +```typescript +import { BundlerCache, hashContent, createCacheKey, validateSource, executeCode } from '@frontmcp/uipack/bundler'; + +// Create cache for bundled results +const cache = new BundlerCache({ maxSize: 100, ttl: 60000 }); + +// Hash content for cache keys +const hash = hashContent(sourceCode); + +// Validate source code security +const violations = validateSource(code, policy); +``` + +## Validation + +```typescript +import { validateOptions, createErrorBox } from '@frontmcp/uipack/validation'; + +const result = validateOptions(options, { + schema: MySchema, + componentName: 'MyComponent', }); -// Get output mode for client -const mode = getOutputModeForClient('openai'); +if (!result.success) { + return result.error; // HTML error box +} ``` ## Testing Requirements @@ -241,6 +224,6 @@ Note: No React dependency! ## Related Packages -- **@frontmcp/ui** - React components, hooks, SSR rendering +- **@frontmcp/ui** - React components, hooks, SSR rendering, HTML components - **@frontmcp/sdk** - Core FrontMCP SDK - **@frontmcp/testing** - E2E testing utilities diff --git a/libs/uipack/src/build/builders/base-builder.ts b/libs/uipack/src/build/builders/base-builder.ts new file mode 100644 index 00000000..cad367b5 --- /dev/null +++ b/libs/uipack/src/build/builders/base-builder.ts @@ -0,0 +1,393 @@ +/** + * Base Builder + * + * Abstract base class for Static, Hybrid, and Inline builders. + * Provides common functionality for template detection, transpilation, + * and HTML generation. + * + * @packageDocumentation + */ + +import { transform } from 'esbuild'; +import type { + BuilderOptions, + CdnMode, + TemplateDetection, + TranspileOptions, + TranspileResult, +} from './types'; +import type { UITemplateConfig, TemplateContext, TemplateBuilderFn } from '../../types'; +import type { ThemeConfig } from '../../theme'; +import { DEFAULT_THEME, mergeThemes, buildThemeCss, buildFontPreconnect, buildFontStylesheets } from '../../theme'; +import { createTransformConfig, createExternalizedConfig, DEFAULT_EXTERNALS } from './esbuild-config'; +import { BRIDGE_SCRIPT_TAGS } from '../../bridge-runtime'; +import { HYBRID_DATA_PLACEHOLDER, HYBRID_INPUT_PLACEHOLDER } from '../hybrid-data'; +import { escapeHtml } from '../../utils'; + +// ============================================ +// Base Builder Class +// ============================================ + +/** + * Abstract base builder class. + * + * Provides common functionality: + * - Template type detection + * - Component transpilation via esbuild + * - Theme CSS generation + * - HTML document scaffolding + */ +export abstract class BaseBuilder { + /** + * CDN loading mode. + */ + protected readonly cdnMode: CdnMode; + + /** + * Whether to minify output. + */ + protected readonly minify: boolean; + + /** + * Theme configuration. + */ + protected readonly theme: ThemeConfig; + + /** + * Whether to include source maps. + */ + protected readonly sourceMaps: boolean; + + constructor(options: BuilderOptions = {}) { + this.cdnMode = options.cdnMode ?? 'cdn'; + this.minify = options.minify ?? false; + this.theme = options.theme ? mergeThemes(DEFAULT_THEME, options.theme) : DEFAULT_THEME; + this.sourceMaps = options.sourceMaps ?? false; + } + + // ============================================ + // Template Detection + // ============================================ + + /** + * Detect the type of a template. + * + * @param template - Template to detect + * @returns Detection result with type and renderer info + */ + protected detectTemplate( + template: UITemplateConfig['template'] + ): TemplateDetection { + // String template + if (typeof template === 'string') { + return { + type: 'html-string', + renderer: 'html', + needsTranspilation: false, + }; + } + + // Function template + if (typeof template === 'function') { + // Check if it's a React component (has $$typeof or returns JSX) + const funcStr = template.toString(); + if ( + funcStr.includes('jsx') || + funcStr.includes('createElement') || + funcStr.includes('React') || + (template as unknown as { $$typeof?: symbol }).$$typeof + ) { + return { + type: 'react-component', + renderer: 'react', + needsTranspilation: true, + }; + } + + // HTML builder function + return { + type: 'html-function', + renderer: 'html', + needsTranspilation: false, + }; + } + + // React element + if ( + template && + typeof template === 'object' && + (template as unknown as { $$typeof?: symbol }).$$typeof + ) { + return { + type: 'react-element', + renderer: 'react', + needsTranspilation: true, + }; + } + + // Default to HTML + return { + type: 'html-string', + renderer: 'html', + needsTranspilation: false, + }; + } + + // ============================================ + // Transpilation + // ============================================ + + /** + * Transpile a component using esbuild. + * + * @param source - Source code to transpile + * @param options - Transpile options + * @returns Transpile result + */ + protected async transpile( + source: string, + options: TranspileOptions = {} + ): Promise { + const externals = options.externals || DEFAULT_EXTERNALS; + const config = options.externals + ? createExternalizedConfig(options) + : createTransformConfig(options); + + const result = await transform(source, config); + + return { + code: result.code, + map: result.map, + size: Buffer.byteLength(result.code, 'utf8'), + externalizedImports: externals, + }; + } + + /** + * Render an HTML template. + * + * @param template - HTML template (string or function) + * @param context - Template context with input/output + * @returns Rendered HTML string + */ + protected renderHtmlTemplate( + template: string | TemplateBuilderFn, + context: TemplateContext + ): string { + if (typeof template === 'string') { + return template; + } + + return template(context); + } + + // ============================================ + // HTML Generation + // ============================================ + + /** + * Build the section of the HTML document. + * + * @param options - Head options + * @returns HTML head content + */ + protected buildHead(options: { + title: string; + includeBridge?: boolean; + includeCdn?: boolean; + includeTheme?: boolean; + customStyles?: string; + }): string { + const { + title, + includeBridge = true, + includeCdn = this.cdnMode === 'cdn', + includeTheme = true, + customStyles = '', + } = options; + + const parts: string[] = [ + '', + '', + '', + `${escapeHtml(title)}`, + ]; + + // Font preconnect and stylesheets + parts.push(buildFontPreconnect()); + parts.push(buildFontStylesheets({ inter: true })); + + // Theme CSS + if (includeTheme) { + const themeCss = buildThemeCss(this.theme); + const customCss = this.theme.customCss || ''; + parts.push(` + + `); + } + + // Custom styles + if (customStyles) { + parts.push(``); + } + + // CDN scripts (Tailwind, etc.) + if (includeCdn) { + parts.push(''); + } + + // Bridge runtime + if (includeBridge) { + parts.push(BRIDGE_SCRIPT_TAGS.universal); + } + + parts.push(''); + + return parts.join('\n'); + } + + /** + * Build data injection script. + * + * @param options - Injection options + * @returns Script tag with data injection + */ + protected buildDataInjectionScript(options: { + toolName: string; + input?: unknown; + output?: unknown; + usePlaceholders?: boolean; + }): string { + const { toolName, input, output, usePlaceholders = false } = options; + + if (usePlaceholders) { + return ` + + `; + } + + return ` + + `; + } + + /** + * Wrap content in a complete HTML document. + * + * @param options - Wrap options + * @returns Complete HTML document + */ + protected wrapInHtmlDocument(options: { + head: string; + body: string; + bodyClass?: string; + }): string { + const { head, body, bodyClass = '' } = options; + + return ` + +${head} + +${body} + +`; + } + + // ============================================ + // Utility Methods + // ============================================ + + /** + * Calculate content hash. + * + * @param content - Content to hash + * @returns Hash string + */ + protected async calculateHash(content: string): Promise { + if (typeof crypto !== 'undefined' && crypto.subtle) { + const encoder = new TextEncoder(); + const data = encoder.encode(content); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, 16); + } + + // Simple fallback hash + let hash = 0; + for (let i = 0; i < content.length; i++) { + const char = content.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; + } + return Math.abs(hash).toString(16).padStart(8, '0'); + } + + /** + * Estimate gzipped size. + * + * @param content - Content to estimate + * @returns Estimated gzipped size in bytes + */ + protected estimateGzipSize(content: string): number { + // Rough estimate: gzip typically achieves 70-90% compression on HTML + return Math.round(Buffer.byteLength(content, 'utf8') * 0.25); + } + + /** + * Create template context. + * + * @param input - Tool input + * @param output - Tool output + * @returns Template context + */ + protected createContext( + input: In, + output: Out + ): TemplateContext { + return { + input, + output, + helpers: { + escapeHtml, + formatDate: (date: Date | string, format?: string): string => { + const d = typeof date === 'string' ? new Date(date) : date; + if (isNaN(d.getTime())) return String(date); + if (format === 'iso') return d.toISOString(); + if (format === 'time') return d.toLocaleTimeString(); + if (format === 'datetime') return d.toLocaleString(); + return d.toLocaleDateString(); + }, + formatCurrency: (amount: number, currency = 'USD'): string => { + return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount); + }, + uniqueId: (prefix = 'mcp'): string => { + return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`; + }, + jsonEmbed: (data: unknown): string => { + const json = JSON.stringify(data); + if (json === undefined) return 'undefined'; + return json + .replace(//g, '\\u003e') + .replace(/&/g, '\\u0026') + .replace(/'/g, '\\u0027'); + }, + }, + }; + } +} diff --git a/libs/uipack/src/build/builders/esbuild-config.ts b/libs/uipack/src/build/builders/esbuild-config.ts new file mode 100644 index 00000000..b898a430 --- /dev/null +++ b/libs/uipack/src/build/builders/esbuild-config.ts @@ -0,0 +1,222 @@ +/** + * esbuild Configuration for Builders + * + * Shared esbuild configuration for component transpilation. + * Supports externalization for hybrid mode and inline bundling. + * + * @packageDocumentation + */ + +import type { TransformOptions } from 'esbuild'; +import type { TranspileOptions } from './types'; + +// ============================================ +// Default Externals +// ============================================ + +/** + * Default packages to externalize in hybrid mode. + * These packages are loaded from the vendor shell's CDN scripts. + */ +export const DEFAULT_EXTERNALS = [ + 'react', + 'react-dom', + 'react/jsx-runtime', + 'react/jsx-dev-runtime', + '@frontmcp/ui', + '@frontmcp/ui/*', +]; + +/** + * Global variable mappings for externalized packages. + * Used to create namespace polyfills in the component chunk. + */ +export const EXTERNAL_GLOBALS: Record = { + 'react': 'React', + 'react-dom': 'ReactDOM', + 'react/jsx-runtime': 'jsxRuntime', + 'react/jsx-dev-runtime': 'jsxRuntime', + '@frontmcp/ui': 'FrontMcpUI', +}; + +// ============================================ +// Transform Configuration +// ============================================ + +/** + * Create esbuild transform options for component transpilation. + * + * @param options - Transpile options + * @returns esbuild transform options + */ +export function createTransformConfig(options: TranspileOptions = {}): TransformOptions { + const { + format = 'esm', + target = 'es2020', + minify = false, + jsxImportSource = 'react', + } = options; + + return { + loader: 'tsx', + format, + target, + minify, + treeShaking: true, + jsx: 'automatic', + jsxImportSource, + sourcemap: options.minify ? false : 'inline', + }; +} + +/** + * Create esbuild transform options for externalized component chunks. + * + * This configuration produces code that: + * 1. Imports React/deps from external globals + * 2. Uses ESM format for dynamic import + * 3. Has all FrontMCP UI components externalized + * + * @param options - Transpile options + * @returns esbuild transform options with externalization + */ +export function createExternalizedConfig(options: TranspileOptions = {}): TransformOptions { + const baseConfig = createTransformConfig({ + ...options, + format: 'esm', + }); + + return { + ...baseConfig, + // Add banner to define external namespace objects + banner: createExternalsBanner(options.externals || DEFAULT_EXTERNALS), + }; +} + +/** + * Create JavaScript banner that defines external namespace objects. + * + * This banner is prepended to the transpiled code and provides + * the global variables that the externalized imports reference. + * + * @param externals - List of external package names + * @returns JavaScript banner code + */ +export function createExternalsBanner(externals: string[]): string { + const lines: string[] = [ + '// Externalized dependencies - loaded from shell globals', + ]; + + for (const pkg of externals) { + const globalName = EXTERNAL_GLOBALS[pkg]; + if (globalName) { + // Create namespace object that references the global + const safeName = pkg.replace(/[^a-zA-Z0-9]/g, '_'); + lines.push(`const __external_${safeName} = window.${globalName};`); + } + } + + return lines.join('\n'); +} + +/** + * Create inline bundle configuration. + * + * This configuration bundles everything together for inline mode, + * including React and all dependencies. + * + * @param options - Transpile options + * @returns esbuild transform options for inline bundling + */ +export function createInlineConfig(options: TranspileOptions = {}): TransformOptions { + return createTransformConfig({ + ...options, + format: 'iife', + minify: options.minify ?? true, + }); +} + +// ============================================ +// CDN Script Generation +// ============================================ + +/** + * CDN URLs for external dependencies. + */ +export const CDN_URLS = { + react: 'https://esm.sh/react@19', + reactDom: 'https://esm.sh/react-dom@19', + reactJsxRuntime: 'https://esm.sh/react@19/jsx-runtime', + frontmcpUI: 'https://esm.sh/@frontmcp/ui', + tailwind: 'https://cdn.tailwindcss.com', +} as const; + +/** + * Cloudflare CDN URLs (for Claude compatibility). + */ +export const CLOUDFLARE_CDN_URLS = { + react: 'https://cdnjs.cloudflare.com/ajax/libs/react/19.0.0/umd/react.production.min.js', + reactDom: 'https://cdnjs.cloudflare.com/ajax/libs/react-dom/19.0.0/umd/react-dom.production.min.js', + tailwind: 'https://cdn.tailwindcss.com', +} as const; + +/** + * Generate CDN script tags for the vendor shell. + * + * @param useCloudflare - Use Cloudflare CDN (for Claude) + * @returns HTML script tags + */ +export function generateCdnScriptTags(useCloudflare = false): string { + const urls = useCloudflare ? CLOUDFLARE_CDN_URLS : CDN_URLS; + + if (useCloudflare) { + // Cloudflare uses UMD builds + return ` + + + + `; + } + + // esm.sh uses ES modules with import map + return ` + + + `; +} + +/** + * Generate global namespace setup script. + * + * This script exposes React and other modules as globals + * for the externalized component chunks to use. + * + * @returns JavaScript code to set up globals + */ +export function generateGlobalsSetupScript(): string { + return ` + + `; +} diff --git a/libs/uipack/src/build/builders/hybrid-builder.ts b/libs/uipack/src/build/builders/hybrid-builder.ts new file mode 100644 index 00000000..7c93d224 --- /dev/null +++ b/libs/uipack/src/build/builders/hybrid-builder.ts @@ -0,0 +1,401 @@ +/** + * Hybrid Builder + * + * Builds vendor shell (cached, shared) + component chunks (per-tool). + * Optimal for OpenAI where shell is fetched once via resource URI and cached. + * + * Build Phase: + * - Vendor shell: React, Bridge, UI components from CDN + * - Component chunks: Transpiled with externalized dependencies + * + * Preview Phase: + * - OpenAI: Shell via resource://, component in tool response + * - Claude: Combine shell + component (inline delivery) + * - Generic: Same as OpenAI with frontmcp namespace + * + * @packageDocumentation + */ + +import { BaseBuilder } from './base-builder'; +import type { + BuilderOptions, + BuildToolOptions, + HybridBuildResult, + IHybridBuilder, +} from './types'; +import type { UITemplateConfig, TemplateBuilderFn } from '../../types'; +import { + generateCdnScriptTags, + generateGlobalsSetupScript, + DEFAULT_EXTERNALS, +} from './esbuild-config'; + +// ============================================ +// Hybrid Builder +// ============================================ + +/** + * Hybrid builder for creating vendor shells and component chunks. + * + * @example + * ```typescript + * const builder = new HybridBuilder({ cdnMode: 'cdn' }); + * + * // Build vendor shell (cached, shared across all tools) + * const vendorShell = await builder.buildVendorShell(); + * + * // Build component chunk per tool + * const componentChunk = await builder.buildComponentChunk(WeatherWidget); + * + * // For Claude, combine into inline HTML + * const html = builder.combineForInline(vendorShell, componentChunk, input, output); + * ``` + */ +export class HybridBuilder extends BaseBuilder implements IHybridBuilder { + readonly mode = 'hybrid' as const; + + /** + * Cached vendor shell. + */ + private vendorShellCache: string | null = null; + + constructor(options: BuilderOptions = {}) { + super(options); + } + + /** + * Build a hybrid result with vendor shell and component chunk. + * + * @param options - Build options + * @returns Hybrid build result + */ + async build( + options: BuildToolOptions + ): Promise { + const startTime = Date.now(); + const { template, toolName } = options; + + // Build or retrieve cached vendor shell + const vendorShell = await this.buildVendorShell(); + + // Build component chunk for this specific tool + const componentChunk = await this.buildComponentChunk(template.template); + + // Generate resource URI for the shell + const shellResourceUri = `resource://widget/${toolName}/shell`; + + // Calculate metrics + const combinedHash = await this.calculateHash(vendorShell + componentChunk); + const shellSize = Buffer.byteLength(vendorShell, 'utf8'); + const componentSize = Buffer.byteLength(componentChunk, 'utf8'); + + // Detect renderer type + const detection = this.detectTemplate(template.template); + + return { + mode: 'hybrid', + vendorShell, + componentChunk, + shellResourceUri, + hash: combinedHash, + shellSize, + componentSize, + rendererType: detection.renderer, + buildTime: new Date(startTime).toISOString(), + }; + } + + /** + * Build the vendor shell (shared across all tools). + * + * The vendor shell contains: + * - React/ReactDOM from CDN + * - FrontMCP Bridge runtime + * - Theme CSS and fonts + * - Component injection point + * + * @returns Vendor shell HTML + */ + async buildVendorShell(): Promise { + // Return cached shell if available + if (this.vendorShellCache) { + return this.vendorShellCache; + } + + // Build head with all dependencies + const head = this.buildHead({ + title: 'FrontMCP Widget', + includeBridge: true, + includeCdn: this.cdnMode === 'cdn', + includeTheme: true, + }); + + // CDN scripts for React + const cdnScripts = this.cdnMode === 'cdn' + ? generateCdnScriptTags(false) + generateGlobalsSetupScript() + : ''; + + // Component injection point + const body = ` + + ${cdnScripts} +
+
+ +
+
+ + + + +

Loading component...

+
+
+
+
+ + + + `; + + const html = this.wrapInHtmlDocument({ + head, + body, + bodyClass: 'antialiased', + }); + + // Cache the shell + this.vendorShellCache = html; + + return html; + } + + /** + * Build a component chunk for a specific template. + * + * The component chunk is transpiled with externalized dependencies + * (React, etc.) that are provided by the vendor shell. + * + * @param template - Component template + * @returns Transpiled component code + */ + async buildComponentChunk( + template: UITemplateConfig['template'] + ): Promise { + const detection = this.detectTemplate(template); + + if (detection.renderer === 'html') { + // For HTML templates, create a simple wrapper component + return this.wrapHtmlAsComponent(template as string | TemplateBuilderFn); + } + + // For React templates, we need to serialize and transpile + // This is a simplified approach - full implementation would use bundling + if (typeof template === 'function') { + return this.transpileReactComponent(template); + } + + // Fallback for React elements + return ` + // Externalized React component + const React = window.React; + + export default function Widget({ input, output }) { + return React.createElement('div', { + className: 'p-4', + }, React.createElement('pre', null, JSON.stringify(output, null, 2))); + } + `; + } + + /** + * Combine shell and component for Claude/inline delivery. + * + * @param shell - Vendor shell HTML + * @param component - Component chunk code + * @param input - Tool input data + * @param output - Tool output data + * @returns Complete HTML with embedded component and data + */ + combineForInline( + shell: string, + component: string, + input: unknown, + output: unknown + ): string { + // Inject data + let result = shell + .replace('window.__mcpToolInput = {};', `window.__mcpToolInput = ${JSON.stringify(input)};`) + .replace('window.__mcpToolOutput = {};', `window.__mcpToolOutput = ${JSON.stringify(output)};`) + .replace('window.__mcpStructuredContent = {};', `window.__mcpStructuredContent = ${JSON.stringify(output)};`); + + // Inject component code directly + const componentInjection = ` + + `; + + // Insert before + result = result.replace('', componentInjection + '\n'); + + return result; + } + + // ============================================ + // Private Methods + // ============================================ + + /** + * Wrap HTML template as a React component. + */ + private wrapHtmlAsComponent( + template: string | TemplateBuilderFn + ): string { + if (typeof template === 'string') { + return ` + // HTML template wrapped as component + const React = window.React; + + export default function Widget({ input, output }) { + return React.createElement('div', { + dangerouslySetInnerHTML: { __html: ${JSON.stringify(template)} } + }); + } + `; + } + + // For function templates, we need to execute them + return ` + // HTML function template wrapped as component + const React = window.React; + + export default function Widget({ input, output }) { + const html = (${template.toString()})({ input, output, helpers: {} }); + return React.createElement('div', { + dangerouslySetInnerHTML: { __html: html } + }); + } + `; + } + + /** + * Transpile a React component function. + */ + private async transpileReactComponent(component: Function): Promise { + // Serialize the function + const funcString = component.toString(); + const componentName = component.name || 'Widget'; + + // Create a module that exports the component + const source = ` + // Externalized React component + const React = window.React; + + const ${componentName} = ${funcString}; + + export default ${componentName}; + export { ${componentName} as Widget, ${componentName} as Component }; + `; + + // Transpile with esbuild + try { + const result = await this.transpile(source, { + externals: DEFAULT_EXTERNALS, + format: 'esm', + minify: this.minify, + }); + return result.code; + } catch (error) { + // Fallback if transpilation fails + console.warn('[HybridBuilder] Transpilation failed, using source directly:', error); + return source; + } + } +} diff --git a/libs/uipack/src/build/builders/index.ts b/libs/uipack/src/build/builders/index.ts new file mode 100644 index 00000000..48095928 --- /dev/null +++ b/libs/uipack/src/build/builders/index.ts @@ -0,0 +1,50 @@ +/** + * UI Builders + * + * Three build modes for creating universal HTML documents: + * - Static: Full HTML with placeholders, inject data at preview time + * - Hybrid: Vendor shell + component chunks, optimal for OpenAI + * - Inline: Minimal loader + full HTML per request, best for development + * + * @packageDocumentation + */ + +// Types +export type { + BuildMode, + CdnMode, + BuilderOptions, + BuildToolOptions, + StaticBuildResult, + HybridBuildResult, + InlineBuildResult, + BuilderResult, + Builder, + IStaticBuilder, + IHybridBuilder, + IInlineBuilder, + TemplateType, + TemplateDetection, + TranspileOptions, + TranspileResult, +} from './types'; + +// Builders +export { BaseBuilder } from './base-builder'; +export { StaticBuilder } from './static-builder'; +export { HybridBuilder } from './hybrid-builder'; +export { InlineBuilder } from './inline-builder'; + +// esbuild configuration utilities +export { + DEFAULT_EXTERNALS, + EXTERNAL_GLOBALS, + CDN_URLS, + CLOUDFLARE_CDN_URLS, + createTransformConfig, + createExternalizedConfig, + createInlineConfig, + createExternalsBanner, + generateCdnScriptTags, + generateGlobalsSetupScript, +} from './esbuild-config'; diff --git a/libs/uipack/src/build/builders/inline-builder.ts b/libs/uipack/src/build/builders/inline-builder.ts new file mode 100644 index 00000000..98e280aa --- /dev/null +++ b/libs/uipack/src/build/builders/inline-builder.ts @@ -0,0 +1,384 @@ +/** + * Inline Builder + * + * Builds minimal loader shell (for discovery) + full HTML per request. + * Best for development/review where each request gets complete widget. + * + * Build Phase: + * - Minimal loader with bridge and loading indicator + * - Injector script that waits for full HTML in tool response + * + * Preview Phase: + * - OpenAI: Loader at discovery, full HTML replaces document on tool call + * - Claude: Full HTML returned directly (no loader needed) + * - Generic: Same as OpenAI with frontmcp namespace + * + * @packageDocumentation + */ + +import { BaseBuilder } from './base-builder'; +import type { + BuilderOptions, + BuildToolOptions, + InlineBuildResult, + IInlineBuilder, +} from './types'; +import type { UITemplateConfig, TemplateBuilderFn } from '../../types'; +import { generateCdnScriptTags, generateGlobalsSetupScript } from './esbuild-config'; + +// ============================================ +// Inline Builder +// ============================================ + +/** + * Inline builder for development and review scenarios. + * + * @example + * ```typescript + * const builder = new InlineBuilder({ cdnMode: 'cdn' }); + * + * // Get loader for tools/list + * const loader = builder.buildLoader('get_weather'); + * + * // Build full widget for each tool call + * const fullHtml = await builder.buildFullWidget( + * WeatherWidget, + * { location: 'NYC' }, + * { temperature: 72 } + * ); + * ``` + */ +export class InlineBuilder extends BaseBuilder implements IInlineBuilder { + readonly mode = 'inline' as const; + + constructor(options: BuilderOptions = {}) { + super(options); + } + + /** + * Build an inline result with loader and full widget generator. + * + * @param options - Build options + * @returns Inline build result + */ + async build( + options: BuildToolOptions + ): Promise { + const startTime = Date.now(); + const { template, toolName } = options; + + // Build the loader shell + const loaderShell = this.buildLoader(toolName); + + // Create the full widget builder function + // Cast input/output to the template types - they're unknown at call time + // but the template expects the correct types for rendering + const buildFullWidget = async (input: unknown, output: unknown): Promise => { + return this.buildFullWidget(template.template, input as In, output as Out); + }; + + // Calculate metrics + const hash = await this.calculateHash(loaderShell); + const loaderSize = Buffer.byteLength(loaderShell, 'utf8'); + + // Detect renderer type + const detection = this.detectTemplate(template.template); + + return { + mode: 'inline', + loaderShell, + buildFullWidget, + hash, + loaderSize, + rendererType: detection.renderer, + buildTime: new Date(startTime).toISOString(), + }; + } + + /** + * Build the minimal loader shell. + * + * The loader contains: + * - FrontMCP Bridge runtime + * - Loading indicator + * - Injector script that replaces document on tool response + * + * @param toolName - Name of the tool + * @returns Loader HTML + */ + buildLoader(toolName: string): string { + const head = this.buildHead({ + title: `${toolName} Widget`, + includeBridge: true, + includeCdn: this.cdnMode === 'cdn', + includeTheme: true, + }); + + const body = ` + + +
+
+ + + + +

Loading widget...

+
+
+ + + + + + `; + + return this.wrapInHtmlDocument({ + head, + body, + bodyClass: 'antialiased', + }); + } + + /** + * Build full widget HTML with embedded data. + * + * @param template - Component template + * @param input - Tool input data + * @param output - Tool output data + * @returns Complete HTML with all dependencies and data + */ + async buildFullWidget( + template: UITemplateConfig['template'], + input: In, + output: Out + ): Promise { + const detection = this.detectTemplate(template); + + // Build head with all dependencies + const head = this.buildHead({ + title: 'FrontMCP Widget', + includeBridge: true, + includeCdn: this.cdnMode === 'cdn', + includeTheme: true, + }); + + // Build data injection + const dataScript = this.buildDataInjectionScript({ + toolName: 'widget', + input, + output, + usePlaceholders: false, + }); + + // Build content based on template type + let content: string; + + if (detection.renderer === 'html') { + // HTML template - render directly + const context = this.createContext(input, output); + + if (typeof template === 'function') { + content = (template as TemplateBuilderFn)(context); + } else { + content = template as string; + } + + content = ` +
+ ${content} +
+ `; + } else { + // React template - build client-side rendering + content = this.buildReactContent(template, input, output); + } + + // Assemble body + const body = ` + ${dataScript} + ${detection.renderer === 'react' ? this.buildReactScripts() : ''} + ${content} + `; + + const html = this.wrapInHtmlDocument({ + head, + body, + bodyClass: 'antialiased', + }); + + return this.minify ? this.minifyHtml(html) : html; + } + + // ============================================ + // Private Methods + // ============================================ + + /** + * Build React scripts for client-side rendering. + */ + private buildReactScripts(): string { + if (this.cdnMode === 'cdn') { + return generateCdnScriptTags(false) + generateGlobalsSetupScript(); + } + + // For inline mode, we'd bundle React + return ` + + `; + } + + /** + * Build React content with client-side rendering. + */ + private buildReactContent( + _template: UITemplateConfig['template'], + _input: In, + _output: Out + ): string { + return ` +
+
+
+
+

Initializing...

+
+
+
+
+ + + `; + } + + /** + * Simple HTML minification. + */ + private minifyHtml(html: string): string { + return html + .replace(/>\s+<') + .replace(/\s{2,}/g, ' ') + .trim(); + } +} diff --git a/libs/uipack/src/build/builders/static-builder.ts b/libs/uipack/src/build/builders/static-builder.ts new file mode 100644 index 00000000..d6fc9ee8 --- /dev/null +++ b/libs/uipack/src/build/builders/static-builder.ts @@ -0,0 +1,294 @@ +/** + * Static Builder + * + * Builds complete HTML documents with placeholders for data injection. + * The shell is cached and reused; only data changes per request. + * + * Build Phase: + * - Full HTML with theme, CDN scripts, bridge runtime + * - Component code (transpiled if React/MDX) + * - Placeholders for input/output data + * + * Preview Phase: + * - Replace placeholders with actual data + * - Platform-specific handling (OpenAI, Claude, Generic) + * + * @packageDocumentation + */ + +import { BaseBuilder } from './base-builder'; +import type { + BuilderOptions, + BuildToolOptions, + StaticBuildResult, + IStaticBuilder, +} from './types'; +import type { UITemplateConfig, TemplateBuilderFn } from '../../types'; +import { injectHybridDataFull, HYBRID_DATA_PLACEHOLDER, HYBRID_INPUT_PLACEHOLDER } from '../hybrid-data'; +import { generateCdnScriptTags, generateGlobalsSetupScript } from './esbuild-config'; + +// ============================================ +// Static Builder +// ============================================ + +/** + * Static builder for creating cached HTML shells with placeholders. + * + * @example + * ```typescript + * const builder = new StaticBuilder({ cdnMode: 'cdn' }); + * + * // Build once (cached) + * const result = await builder.build({ + * template: WeatherWidget, + * toolName: 'get_weather', + * }); + * + * // Inject data per request (fast) + * const html = builder.injectData(result.html, input, output); + * ``` + */ +export class StaticBuilder extends BaseBuilder implements IStaticBuilder { + readonly mode = 'static' as const; + + constructor(options: BuilderOptions = {}) { + super(options); + } + + /** + * Build a static HTML shell with placeholders. + * + * @param options - Build options + * @returns Static build result + */ + async build( + options: BuildToolOptions + ): Promise { + const startTime = Date.now(); + const { template, toolName, title = `${toolName} Widget` } = options; + + // Detect template type + const detection = this.detectTemplate(template.template); + + // Render or transpile based on type + let bodyContent: string; + + if (detection.renderer === 'html') { + // HTML templates - render directly with placeholder context + const context = this.createContext( + options.sampleInput ?? ({} as In), + options.sampleOutput ?? ({} as Out) + ); + + if (typeof template.template === 'function') { + bodyContent = (template.template as TemplateBuilderFn)(context); + } else { + bodyContent = template.template as string; + } + + // Wrap in a container for client-side updates + bodyContent = ` +
+ ${bodyContent} +
+ `; + } else { + // React/MDX templates - build client-side rendering shell + bodyContent = this.buildReactShell(template, toolName); + } + + // Build head with all dependencies + const head = this.buildHead({ + title, + includeBridge: true, + includeCdn: this.cdnMode === 'cdn', + includeTheme: true, + }); + + // Build data injection with placeholders + const dataScript = this.buildDataInjectionScript({ + toolName, + usePlaceholders: true, + }); + + // Additional scripts for React + let additionalScripts = ''; + if (detection.renderer === 'react') { + additionalScripts = this.cdnMode === 'cdn' + ? generateCdnScriptTags(false) + generateGlobalsSetupScript() + : this.buildInlineReactRuntime(); + } + + // Assemble body + const body = ` + ${dataScript} + ${additionalScripts} + ${bodyContent} + `; + + // Create complete HTML document + const html = this.wrapInHtmlDocument({ + head, + body, + bodyClass: 'antialiased', + }); + + // Optionally minify + const finalHtml = this.minify ? this.minifyHtml(html) : html; + + // Calculate metrics + const hash = await this.calculateHash(finalHtml); + const size = Buffer.byteLength(finalHtml, 'utf8'); + const gzipSize = this.estimateGzipSize(finalHtml); + + return { + mode: 'static', + html: finalHtml, + hash, + size, + gzipSize, + placeholders: { + hasOutput: finalHtml.includes(HYBRID_DATA_PLACEHOLDER), + hasInput: finalHtml.includes(HYBRID_INPUT_PLACEHOLDER), + }, + rendererType: detection.renderer, + buildTime: new Date(startTime).toISOString(), + }; + } + + /** + * Inject data into a pre-built shell. + * + * @param shell - HTML shell with placeholders + * @param input - Tool input data + * @param output - Tool output data + * @returns HTML with data injected + */ + injectData(shell: string, input: unknown, output: unknown): string { + return injectHybridDataFull(shell, input, output); + } + + // ============================================ + // Private Methods + // ============================================ + + /** + * Build React rendering shell. + * + * Creates a client-side React rendering setup that: + * 1. Waits for runtime to be ready + * 2. Loads component code + * 3. Renders into #root with data from window.__mcpToolOutput + */ + private buildReactShell( + template: UITemplateConfig, + _toolName: string + ): string { + // For React components, we need to serialize them for client-side execution + // This is a simplified approach - full implementation would use bundling + const _componentName = this.getComponentName(template.template); + + return ` +
+
+
+
+ + + + +

Loading...

+
+
+
+
+ + + `; + } + + /** + * Build inline React runtime (for offline mode). + */ + private buildInlineReactRuntime(): string { + // For inline mode, we'd bundle React directly + // This is a placeholder - full implementation would include minified React + return ` + + `; + } + + /** + * Get component name from template. + */ + private getComponentName(template: unknown): string { + if (typeof template === 'function') { + return template.name || 'Widget'; + } + return 'Widget'; + } + + /** + * Simple HTML minification. + */ + private minifyHtml(html: string): string { + return html + .replace(/>\s+<') + .replace(/\s{2,}/g, ' ') + .trim(); + } +} diff --git a/libs/uipack/src/build/builders/types.ts b/libs/uipack/src/build/builders/types.ts new file mode 100644 index 00000000..da65a38f --- /dev/null +++ b/libs/uipack/src/build/builders/types.ts @@ -0,0 +1,442 @@ +/** + * Builder Types + * + * Type definitions for the Static, Hybrid, and Inline builders. + * These builders create universal HTML documents for MCP clients. + * + * @packageDocumentation + */ + +import type { UITemplateConfig } from '../../types'; +import type { ThemeConfig, DeepPartial } from '../../theme'; + +// ============================================ +// Build Mode +// ============================================ + +/** + * Build mode for widget generation. + * + * - `'static'`: Full HTML with placeholders. Data injected at preview time. + * Best for production - widget is cached, only data changes per request. + * + * - `'hybrid'`: Vendor shell (cached) + component chunks (per-tool). + * Shell delivered via resource URI, component via tool response. + * Optimal for OpenAI where shell is fetched once and cached. + * + * - `'inline'`: Minimal loader at discovery, full HTML per request. + * Best for development/review. Each request gets complete widget. + */ +export type BuildMode = 'static' | 'hybrid' | 'inline'; + +/** + * CDN loading mode for dependencies. + * + * - `'cdn'`: Load React/deps from CDN (smaller HTML, requires network) + * - `'inline'`: Bundle all deps inline (larger HTML, works offline) + */ +export type CdnMode = 'cdn' | 'inline'; + +// ============================================ +// Builder Options +// ============================================ + +/** + * Common options for all builders. + */ +export interface BuilderOptions { + /** + * CDN loading mode for dependencies. + * @default 'cdn' + */ + cdnMode?: CdnMode; + + /** + * Whether to minify the output HTML. + * @default false + */ + minify?: boolean; + + /** + * Theme configuration override. + */ + theme?: DeepPartial; + + /** + * Whether to include source maps. + * @default false + */ + sourceMaps?: boolean; +} + +/** + * Options for building a specific tool's UI. + */ +export interface BuildToolOptions { + /** + * UI template configuration. + */ + template: UITemplateConfig; + + /** + * Name of the tool this UI is for. + */ + toolName: string; + + /** + * Optional title for the HTML document. + */ + title?: string; + + /** + * Sample input for preview/development. + */ + sampleInput?: In; + + /** + * Sample output for preview/development. + */ + sampleOutput?: Out; +} + +// ============================================ +// Build Results +// ============================================ + +/** + * Result from a static build. + * + * Static builds produce a complete HTML document with placeholders + * that are replaced with actual data at preview time. + */ +export interface StaticBuildResult { + /** + * Build mode identifier. + */ + mode: 'static'; + + /** + * Complete HTML document with placeholders. + */ + html: string; + + /** + * Content hash for caching. + */ + hash: string; + + /** + * Size in bytes. + */ + size: number; + + /** + * Estimated gzipped size in bytes. + */ + gzipSize: number; + + /** + * Placeholders present in the HTML. + */ + placeholders: { + hasOutput: boolean; + hasInput: boolean; + }; + + /** + * Renderer type used (html, react, mdx). + */ + rendererType: string; + + /** + * Build timestamp (ISO 8601). + */ + buildTime: string; +} + +/** + * Result from a hybrid build. + * + * Hybrid builds produce a vendor shell (cached, shared across tools) + * and component chunks (per-tool, externalized dependencies). + */ +export interface HybridBuildResult { + /** + * Build mode identifier. + */ + mode: 'hybrid'; + + /** + * Vendor shell HTML (shared across all tools). + * Contains React, Bridge, UI components from CDN. + */ + vendorShell: string; + + /** + * Transpiled component code (externalized). + * Imports React/deps from shell's globals. + */ + componentChunk: string; + + /** + * Resource URI for the vendor shell. + * Used in _meta['openai/outputTemplate']. + */ + shellResourceUri: string; + + /** + * Content hash for caching. + */ + hash: string; + + /** + * Size of vendor shell in bytes. + */ + shellSize: number; + + /** + * Size of component chunk in bytes. + */ + componentSize: number; + + /** + * Renderer type used (html, react, mdx). + */ + rendererType: string; + + /** + * Build timestamp (ISO 8601). + */ + buildTime: string; +} + +/** + * Result from an inline build. + * + * Inline builds produce a minimal loader shell (for discovery) + * and full widget HTML (for each tool execution). + */ +export interface InlineBuildResult { + /** + * Build mode identifier. + */ + mode: 'inline'; + + /** + * Minimal loader HTML (returned at tools/list). + * Contains bridge + loading indicator + injector script. + */ + loaderShell: string; + + /** + * Full widget HTML generator. + * Called with input/output to produce complete HTML. + */ + buildFullWidget: (input: unknown, output: unknown) => Promise; + + /** + * Content hash for the loader. + */ + hash: string; + + /** + * Size of loader in bytes. + */ + loaderSize: number; + + /** + * Renderer type used (html, react, mdx). + */ + rendererType: string; + + /** + * Build timestamp (ISO 8601). + */ + buildTime: string; +} + +/** + * Union type for all build results. + * Named BuilderResult to avoid conflict with the existing BuildResult type in build/index.ts. + */ +export type BuilderResult = StaticBuildResult | HybridBuildResult | InlineBuildResult; + +// ============================================ +// Builder Interfaces +// ============================================ + +/** + * Interface for all builders. + */ +export interface Builder { + /** + * Build mode this builder produces. + */ + readonly mode: BuildMode; + + /** + * Build a tool UI. + */ + build( + options: BuildToolOptions + ): Promise; +} + +/** + * Interface for the static builder. + */ +export interface IStaticBuilder extends Builder { + readonly mode: 'static'; + + /** + * Inject data into a pre-built shell. + */ + injectData( + shell: string, + input: unknown, + output: unknown + ): string; +} + +/** + * Interface for the hybrid builder. + */ +export interface IHybridBuilder extends Builder { + readonly mode: 'hybrid'; + + /** + * Build just the vendor shell (cached, shared). + */ + buildVendorShell(): Promise; + + /** + * Build a component chunk for a specific template. + */ + buildComponentChunk( + template: UITemplateConfig['template'] + ): Promise; + + /** + * Combine shell and component for Claude/inline delivery. + */ + combineForInline( + shell: string, + component: string, + input: unknown, + output: unknown + ): string; +} + +/** + * Interface for the inline builder. + */ +export interface IInlineBuilder extends Builder { + readonly mode: 'inline'; + + /** + * Build the minimal loader shell. + */ + buildLoader(toolName: string): string; + + /** + * Build full widget HTML with embedded data. + */ + buildFullWidget( + template: UITemplateConfig['template'], + input: In, + output: Out + ): Promise; +} + +// ============================================ +// Template Detection +// ============================================ + +/** + * Detected template type. + */ +export type TemplateType = 'html-string' | 'html-function' | 'react-component' | 'react-element' | 'mdx'; + +/** + * Template detection result. + */ +export interface TemplateDetection { + /** + * Detected template type. + */ + type: TemplateType; + + /** + * Renderer to use for this template. + */ + renderer: 'html' | 'react' | 'mdx'; + + /** + * Whether the template needs transpilation. + */ + needsTranspilation: boolean; +} + +// ============================================ +// Transpilation Options +// ============================================ + +/** + * Options for transpiling component code. + */ +export interface TranspileOptions { + /** + * External dependencies to exclude from bundle. + * These will be loaded from the shell's globals. + */ + externals?: string[]; + + /** + * Output format. + * @default 'esm' + */ + format?: 'esm' | 'iife'; + + /** + * Target ES version. + * @default 'es2020' + */ + target?: string; + + /** + * Whether to minify output. + * @default false + */ + minify?: boolean; + + /** + * JSX import source. + * @default 'react' + */ + jsxImportSource?: string; +} + +/** + * Result of transpilation. + */ +export interface TranspileResult { + /** + * Transpiled JavaScript code. + */ + code: string; + + /** + * Source map (if requested). + */ + map?: string; + + /** + * Size in bytes. + */ + size: number; + + /** + * Detected imports that were externalized. + */ + externalizedImports: string[]; +} diff --git a/libs/uipack/src/build/index.ts b/libs/uipack/src/build/index.ts index 0943dc74..bfb73380 100644 --- a/libs/uipack/src/build/index.ts +++ b/libs/uipack/src/build/index.ts @@ -718,3 +718,43 @@ export { } from './ui-components-browser'; export type { BrowserUIComponentsOptions } from './ui-components-browser'; + +// ============================================ +// New Builder Architecture +// ============================================ + +export { + // Types + type BuildMode, + type CdnMode, + type BuilderOptions, + type BuildToolOptions, + type StaticBuildResult, + type HybridBuildResult, + type InlineBuildResult, + type BuilderResult, + type Builder, + type IStaticBuilder, + type IHybridBuilder, + type IInlineBuilder, + type TemplateType, + type TemplateDetection, + type TranspileOptions, + type TranspileResult, + // Builders + BaseBuilder, + StaticBuilder, + HybridBuilder, + InlineBuilder, + // esbuild utilities + DEFAULT_EXTERNALS, + EXTERNAL_GLOBALS, + CDN_URLS, + CLOUDFLARE_CDN_URLS, + createTransformConfig, + createExternalizedConfig, + createInlineConfig, + createExternalsBanner, + generateCdnScriptTags, + generateGlobalsSetupScript, +} from './builders'; diff --git a/libs/uipack/src/build/widget-manifest.ts b/libs/uipack/src/build/widget-manifest.ts index 4c0051ae..8e1086a3 100644 --- a/libs/uipack/src/build/widget-manifest.ts +++ b/libs/uipack/src/build/widget-manifest.ts @@ -31,7 +31,7 @@ import { getDefaultAssets } from './cdn-resources'; import type { ThemeConfig } from '../theme'; import { wrapToolUIUniversal } from '../runtime/wrapper'; import { rendererRegistry } from '../renderers/registry'; -import { detectTemplateType, mdxRenderer } from '../renderers'; +import { detectTemplateType, mdxClientRenderer } from '../renderers'; // File-based template support import { detectTemplateMode, detectFormatFromPath } from '../dependency/types'; @@ -533,9 +533,9 @@ function ensureRenderersRegistered(): void { // Note: React renderer is in @frontmcp/ui package // For React support, use @frontmcp/ui instead of @frontmcp/uipack - // Register MDX renderer if not already registered + // Register MDX client renderer if not already registered if (!rendererRegistry.has('mdx')) { - rendererRegistry.register(mdxRenderer); + rendererRegistry.register(mdxClientRenderer); } renderersInitialized = true; diff --git a/libs/uipack/src/index.ts b/libs/uipack/src/index.ts index fa25cc3b..f1188126 100644 --- a/libs/uipack/src/index.ts +++ b/libs/uipack/src/index.ts @@ -85,6 +85,29 @@ export { isHybridShell, needsInputInjection, getHybridPlaceholders, + // New Architecture Builders + type BuildMode, + type CdnMode, + type BuilderOptions, + type BuildToolOptions, + type StaticBuildResult, + type HybridBuildResult, + type InlineBuildResult, + type BuilderResult, + type Builder, + type IStaticBuilder, + type IHybridBuilder, + type IInlineBuilder, + BaseBuilder, + StaticBuilder, + HybridBuilder, + InlineBuilder, + // esbuild configuration + DEFAULT_EXTERNALS, + CDN_URLS, + createTransformConfig, + createExternalizedConfig, + generateCdnScriptTags, } from './build'; // ============================================ @@ -148,6 +171,37 @@ export { type IIFEGeneratorOptions, } from './bridge-runtime'; +// ============================================ +// Preview Handlers (New Architecture) +// Platform-specific preview generation for +// OpenAI, Claude, and Generic MCP clients +// ============================================ +export { + // Types + type Platform, + // Note: AIPlatformType is also exported from ./adapters (canonical source). + // The preview module re-exports it for convenience. Use either: + // - import { AIPlatformType } from '@frontmcp/uipack' (from adapters) + // - import { AIPlatformType } from '@frontmcp/uipack/preview' (re-exported) + type DiscoveryPreviewOptions, + type ExecutionPreviewOptions, + type BuilderMockData, + type DiscoveryMeta, + type ExecutionMeta, + type PreviewHandler, + type OpenAIMetaFields, + type ClaudeMetaFields, + type FrontMCPMetaFields, + type UIMetaFields, + // Preview Handlers + OpenAIPreview, + ClaudePreview, + GenericPreview, + // Factory Functions + createPreviewHandler, + detectPlatform as detectPreviewPlatform, +} from './preview'; + // ============================================ // Tool Template Builder // ============================================ diff --git a/libs/uipack/src/preview/claude-preview.ts b/libs/uipack/src/preview/claude-preview.ts new file mode 100644 index 00000000..626647d1 --- /dev/null +++ b/libs/uipack/src/preview/claude-preview.ts @@ -0,0 +1,263 @@ +/** + * Claude Preview Handler + * + * Generates metadata for Anthropic Claude platform. + * + * Claude is always inline-only (cannot fetch resources). + * Uses Cloudflare CDN which is trusted by Claude's sandbox. + * + * Discovery (tools/list): + * - No resource registration (Claude can't fetch) + * - Optional loader HTML in _meta for streaming scenarios + * + * Execution (tool/call): + * - Full HTML with injected input/output + * - Uses Cloudflare CDN for dependencies + * + * @packageDocumentation + */ + +import type { + PreviewHandler, + DiscoveryPreviewOptions, + ExecutionPreviewOptions, + DiscoveryMeta, + ExecutionMeta, + ClaudeMetaFields, +} from './types'; +import type { BuilderResult, StaticBuildResult, HybridBuildResult, InlineBuildResult } from '../build/builders/types'; +import { injectHybridDataFull } from '../build/hybrid-data'; +import { CLOUDFLARE_CDN_URLS } from '../build/builders/esbuild-config'; +import { safeJsonForScript, escapeHtml } from '../utils'; + +// ============================================ +// Data Injection Placeholders +// ============================================ + +/** + * Placeholder patterns for hybrid data injection. + * Using regex for more robust matching that handles whitespace variations. + */ +const DATA_PLACEHOLDERS = { + input: /window\.__mcpToolInput\s*=\s*\{\s*\};?/g, + output: /window\.__mcpToolOutput\s*=\s*\{\s*\};?/g, + structuredContent: /window\.__mcpStructuredContent\s*=\s*\{\s*\};?/g, +}; + +// ============================================ +// Claude Preview Handler +// ============================================ + +/** + * Preview handler for Anthropic Claude platform. + * + * @example + * ```typescript + * const preview = new ClaudePreview(); + * + * // For tool/call (Claude is always inline) + * const executionMeta = preview.forExecution({ + * buildResult: staticResult, + * input: { location: 'NYC' }, + * output: { temperature: 72 }, + * }); + * ``` + */ +export class ClaudePreview implements PreviewHandler { + readonly platform = 'claude' as const; + + /** + * Generate metadata for tool discovery (tools/list). + * + * Claude cannot fetch resources, so discovery is minimal. + * We may include a loader HTML for streaming scenarios. + */ + forDiscovery(options: DiscoveryPreviewOptions): DiscoveryMeta { + const { toolName, description } = options; + + // Claude can't fetch resources, so no outputTemplate + // Just indicate that this tool can produce widgets + const _meta: ClaudeMetaFields = { + 'claude/widgetDescription': description || `Widget for ${toolName}`, + 'claude/prefersBorder': true, + }; + + return { + _meta: _meta as Record, + }; + } + + /** + * Generate metadata for tool execution (tool/call). + * + * Always returns complete HTML with embedded data. + */ + forExecution(options: ExecutionPreviewOptions): ExecutionMeta { + const { buildResult, input, output } = options; + + switch (buildResult.mode) { + case 'static': + return this.forExecutionStatic(buildResult, input, output); + + case 'hybrid': + return this.forExecutionHybrid(buildResult, input, output); + + case 'inline': + return this.forExecutionInline(buildResult, input, output); + + default: + throw new Error(`Unknown build mode: ${(buildResult as BuilderResult).mode}`); + } + } + + // ============================================ + // Execution Handlers + // ============================================ + + private forExecutionStatic(result: StaticBuildResult, input: unknown, output: unknown): ExecutionMeta { + // Static mode: Inject data into placeholders + let html = injectHybridDataFull(result.html, input, output); + + // Replace esm.sh CDN with Cloudflare (Claude-compatible) + html = this.replaceWithCloudfareCdn(html); + + const _meta: ClaudeMetaFields = { + 'ui/html': html, + 'ui/mimeType': 'text/html+mcp', + }; + + return { + _meta: _meta as Record, + html, + structuredContent: output, + textContent: JSON.stringify(output, null, 2), + }; + } + + private forExecutionHybrid(result: HybridBuildResult, input: unknown, output: unknown): ExecutionMeta { + // Hybrid mode: Combine shell + component + data + let html = result.vendorShell; + + // Use safeJsonForScript to escape and other HTML-sensitive sequences + const safeInput = safeJsonForScript(input); + const safeOutput = safeJsonForScript(output); + + // Inject data using regex for robust matching (handles whitespace variations) + html = html + .replace(DATA_PLACEHOLDERS.input, `window.__mcpToolInput = ${safeInput};`) + .replace(DATA_PLACEHOLDERS.output, `window.__mcpToolOutput = ${safeOutput};`) + .replace(DATA_PLACEHOLDERS.structuredContent, `window.__mcpStructuredContent = ${safeOutput};`); + + // Inject component + const componentScript = ` + + `; + html = html.replace('', componentScript + '\n'); + + // Replace CDN with Cloudflare + html = this.replaceWithCloudfareCdn(html); + + const _meta: ClaudeMetaFields = { + 'ui/html': html, + 'ui/mimeType': 'text/html+mcp', + }; + + return { + _meta: _meta as Record, + html, + structuredContent: output, + textContent: JSON.stringify(output, null, 2), + }; + } + + private forExecutionInline(result: InlineBuildResult, input: unknown, output: unknown): ExecutionMeta { + // Inline mode: Would use buildFullWidget but that's async + // For Claude, we need to return complete HTML + + // For now, create a simple HTML with the data + const html = this.buildClaudeInlineHtml(input, output); + + const _meta: ClaudeMetaFields = { + 'ui/html': html, + 'ui/mimeType': 'text/html+mcp', + }; + + return { + _meta: _meta as Record, + html, + structuredContent: output, + textContent: JSON.stringify(output, null, 2), + }; + } + + // ============================================ + // Helper Methods + // ============================================ + + /** + * Replace esm.sh CDN URLs with Cloudflare CDN. + * + * Claude's sandbox trusts Cloudflare but not esm.sh. + */ + private replaceWithCloudfareCdn(html: string): string { + // Replace React imports + html = html.replace(/https:\/\/esm\.sh\/react@\d+/g, CLOUDFLARE_CDN_URLS.react); + html = html.replace(/https:\/\/esm\.sh\/react-dom@\d+/g, CLOUDFLARE_CDN_URLS.reactDom); + + // Replace import map with UMD script tags (Cloudflare uses UMD) + const importMapRegex = / + + `; + html = html.replace(importMapRegex, umdScripts); + } + + return html; + } + + /** + * Build simple inline HTML for Claude. + * + * Uses safeJsonForScript for script context and escapeHtml for HTML context + * to prevent XSS attacks from malicious data. + */ + private buildClaudeInlineHtml(input: unknown, output: unknown): string { + // Use safeJsonForScript to escape and other HTML-sensitive sequences + const safeInput = safeJsonForScript(input); + const safeOutput = safeJsonForScript(output); + + // Use escapeHtml for the
 tag content to prevent HTML injection
+    const escapedOutputDisplay = escapeHtml(JSON.stringify(output, null, 2));
+
+    return `
+
+
+  
+  
+  FrontMCP Widget
+  
+  
+
+
+  
+
+  
+
+

Tool Output

+
${escapedOutputDisplay}
+
+
+ +`; + } +} diff --git a/libs/uipack/src/preview/generic-preview.ts b/libs/uipack/src/preview/generic-preview.ts new file mode 100644 index 00000000..33c40d03 --- /dev/null +++ b/libs/uipack/src/preview/generic-preview.ts @@ -0,0 +1,332 @@ +/** + * Generic Preview Handler + * + * Generates metadata for generic MCP clients. + * Uses frontmcp/* namespace with ui/* fallback for compatibility. + * + * Behaves like OpenAI but with different metadata namespaces. + * + * @packageDocumentation + */ + +import type { + PreviewHandler, + DiscoveryPreviewOptions, + ExecutionPreviewOptions, + DiscoveryMeta, + ExecutionMeta, + FrontMCPMetaFields, + BuilderMockData, +} from './types'; +import type { BuilderResult, StaticBuildResult, HybridBuildResult, InlineBuildResult } from '../build/builders/types'; +import { safeJsonForScript } from '../utils'; + +// ============================================ +// Data Injection Placeholders +// ============================================ + +/** + * Placeholder patterns for hybrid data injection. + * Using regex for more robust matching that handles whitespace variations. + */ +const DATA_PLACEHOLDERS = { + input: /window\.__mcpToolInput\s*=\s*\{\s*\};?/g, + output: /window\.__mcpToolOutput\s*=\s*\{\s*\};?/g, + structuredContent: /window\.__mcpStructuredContent\s*=\s*\{\s*\};?/g, +}; + +// ============================================ +// Generic Preview Handler +// ============================================ + +/** + * Preview handler for generic MCP clients. + * + * Uses the same patterns as OpenAI but with frontmcp/* namespace + * and ui/* fallback for maximum compatibility. + * + * @example + * ```typescript + * const preview = new GenericPreview(); + * + * // For tools/list + * const discoveryMeta = preview.forDiscovery({ + * buildResult: hybridResult, + * toolName: 'get_weather', + * }); + * + * // For tool/call + * const executionMeta = preview.forExecution({ + * buildResult: hybridResult, + * input: { location: 'NYC' }, + * output: { temperature: 72 }, + * }); + * ``` + */ +export class GenericPreview implements PreviewHandler { + readonly platform = 'generic' as const; + + /** + * Generate metadata for tool discovery (tools/list). + */ + forDiscovery(options: DiscoveryPreviewOptions): DiscoveryMeta { + const { buildResult, toolName, description } = options; + + switch (buildResult.mode) { + case 'static': + return this.forDiscoveryStatic(buildResult, toolName, description); + + case 'hybrid': + return this.forDiscoveryHybrid(buildResult, toolName, description); + + case 'inline': + return this.forDiscoveryInline(buildResult, toolName, description); + + default: + throw new Error(`Unknown build mode: ${(buildResult as BuilderResult).mode}`); + } + } + + /** + * Generate metadata for tool execution (tool/call). + */ + forExecution(options: ExecutionPreviewOptions): ExecutionMeta { + const { buildResult, input, output, builderMode = false, mockData } = options; + + switch (buildResult.mode) { + case 'static': + return this.forExecutionStatic(buildResult, input, output, builderMode, mockData); + + case 'hybrid': + return this.forExecutionHybrid(buildResult, input, output, builderMode, mockData); + + case 'inline': + return this.forExecutionInline(buildResult, input, output, builderMode, mockData); + + default: + throw new Error(`Unknown build mode: ${(buildResult as BuilderResult).mode}`); + } + } + + // ============================================ + // Discovery Handlers + // ============================================ + + private forDiscoveryStatic(result: StaticBuildResult, toolName: string, _description?: string): DiscoveryMeta { + const resourceUri = `resource://widget/${toolName}`; + + const _meta: FrontMCPMetaFields = { + // Primary namespace + 'frontmcp/outputTemplate': resourceUri, + 'frontmcp/widgetCSP': { + connect_domains: ['esm.sh', 'cdn.tailwindcss.com'], + resource_domains: ['esm.sh', 'cdn.tailwindcss.com', 'fonts.googleapis.com', 'fonts.gstatic.com'], + }, + // Fallback for compatibility + 'ui/mimeType': 'text/html+mcp', + }; + + return { + _meta: _meta as Record, + resourceUri, + resourceContent: result.html, + }; + } + + private forDiscoveryHybrid(result: HybridBuildResult, _toolName: string, _description?: string): DiscoveryMeta { + const _meta: FrontMCPMetaFields = { + 'frontmcp/outputTemplate': result.shellResourceUri, + 'frontmcp/widgetCSP': { + connect_domains: ['esm.sh', 'cdn.tailwindcss.com'], + resource_domains: ['esm.sh', 'cdn.tailwindcss.com', 'fonts.googleapis.com', 'fonts.gstatic.com'], + }, + 'ui/mimeType': 'text/html+mcp', + }; + + return { + _meta: _meta as Record, + resourceUri: result.shellResourceUri, + resourceContent: result.vendorShell, + }; + } + + private forDiscoveryInline(result: InlineBuildResult, _toolName: string, _description?: string): DiscoveryMeta { + const _meta: FrontMCPMetaFields = { + 'frontmcp/html': result.loaderShell, + 'ui/html': result.loaderShell, + 'ui/mimeType': 'text/html+mcp', + }; + + return { + _meta: _meta as Record, + }; + } + + // ============================================ + // Execution Handlers + // ============================================ + + private forExecutionStatic( + result: StaticBuildResult, + input: unknown, + output: unknown, + builderMode: boolean, + mockData?: BuilderMockData, + ): ExecutionMeta { + if (builderMode) { + const html = this.injectBuilderMode(result.html, input, output, mockData); + return { + _meta: { + 'frontmcp/html': html, + 'ui/html': html, + }, + html, + structuredContent: output, + textContent: JSON.stringify(output, null, 2), + }; + } + + // Normal mode: Platform handles data injection + return { + _meta: {}, + structuredContent: output, + textContent: JSON.stringify(output, null, 2), + }; + } + + private forExecutionHybrid( + result: HybridBuildResult, + input: unknown, + output: unknown, + builderMode: boolean, + mockData?: BuilderMockData, + ): ExecutionMeta { + const _meta: FrontMCPMetaFields = { + 'frontmcp/component': result.componentChunk, + }; + + if (builderMode) { + const html = this.combineHybridForBuilder(result, input, output, mockData); + return { + _meta: { + 'frontmcp/html': html, + 'ui/html': html, + }, + html, + structuredContent: output, + textContent: JSON.stringify(output, null, 2), + }; + } + + return { + _meta: _meta as Record, + structuredContent: output, + textContent: JSON.stringify(output, null, 2), + }; + } + + private forExecutionInline( + result: InlineBuildResult, + input: unknown, + output: unknown, + _builderMode: boolean, + _mockData?: BuilderMockData, + ): ExecutionMeta { + // Inline mode returns full HTML + const _meta: FrontMCPMetaFields = { + 'frontmcp/html': '', + 'ui/html': '', + }; + + return { + _meta: _meta as Record, + structuredContent: output, + textContent: JSON.stringify(output, null, 2), + }; + } + + // ============================================ + // Builder Mode Helpers + // ============================================ + + /** + * Inject builder mode mock APIs into HTML. + * + * Uses safeJsonForScript to prevent XSS attacks from malicious data + * containing `` or other HTML-sensitive sequences. + */ + private injectBuilderMode(html: string, input: unknown, output: unknown, mockData?: BuilderMockData): string { + const theme = mockData?.theme || 'light'; + const displayMode = mockData?.displayMode || 'inline'; + const toolResponses = mockData?.toolResponses || {}; + + // Use safeJsonForScript to escape and other HTML-sensitive sequences + const safeTheme = safeJsonForScript(theme); + const safeDisplayMode = safeJsonForScript(displayMode); + const safeToolResponses = safeJsonForScript(toolResponses); + const safeInput = safeJsonForScript(input); + const safeOutput = safeJsonForScript(output); + + // Create mock FrontMCP bridge API + const mockScript = ` + + `; + + return html.replace('', '\n' + mockScript); + } + + /** + * Combine hybrid shell + component for builder mode. + * + * Uses regex patterns for robust placeholder matching and + * safeJsonForScript to prevent XSS attacks. + */ + private combineHybridForBuilder( + result: HybridBuildResult, + input: unknown, + output: unknown, + mockData?: BuilderMockData, + ): string { + let html = result.vendorShell; + + // Use safeJsonForScript to escape and other HTML-sensitive sequences + const safeInput = safeJsonForScript(input); + const safeOutput = safeJsonForScript(output); + + // Inject data using regex for robust matching (handles whitespace variations) + html = html + .replace(DATA_PLACEHOLDERS.input, `window.__mcpToolInput = ${safeInput};`) + .replace(DATA_PLACEHOLDERS.output, `window.__mcpToolOutput = ${safeOutput};`) + .replace(DATA_PLACEHOLDERS.structuredContent, `window.__mcpStructuredContent = ${safeOutput};`); + + // Inject component + const componentScript = ` + + `; + html = html.replace('', componentScript + '\n'); + + // Add builder mode mock + return this.injectBuilderMode(html, input, output, mockData); + } +} diff --git a/libs/uipack/src/preview/index.ts b/libs/uipack/src/preview/index.ts new file mode 100644 index 00000000..4c840200 --- /dev/null +++ b/libs/uipack/src/preview/index.ts @@ -0,0 +1,86 @@ +/** + * Preview Handlers + * + * Platform-specific preview handlers for generating metadata. + * + * @packageDocumentation + */ + +// Types +export type { + Platform, + AIPlatformType, + DiscoveryPreviewOptions, + ExecutionPreviewOptions, + BuilderMockData, + DiscoveryMeta, + ExecutionMeta, + PreviewHandler, + OpenAIMetaFields, + ClaudeMetaFields, + FrontMCPMetaFields, + UIMetaFields, +} from './types'; + +// Preview Handlers +export { OpenAIPreview } from './openai-preview'; +export { ClaudePreview } from './claude-preview'; +export { GenericPreview } from './generic-preview'; + +// ============================================ +// Convenience Factory +// ============================================ + +import { OpenAIPreview } from './openai-preview'; +import { ClaudePreview } from './claude-preview'; +import { GenericPreview } from './generic-preview'; +import type { Platform, PreviewHandler } from './types'; + +/** + * Create a preview handler for the specified platform. + * + * @param platform - Target platform + * @returns Preview handler instance + * + * @example + * ```typescript + * const preview = createPreviewHandler('openai'); + * const meta = preview.forExecution({ buildResult, input, output }); + * ``` + */ +export function createPreviewHandler(platform: Platform): PreviewHandler { + switch (platform) { + case 'openai': + return new OpenAIPreview(); + case 'claude': + return new ClaudePreview(); + case 'generic': + return new GenericPreview(); + default: + throw new Error(`Unknown platform: ${platform}`); + } +} + +/** + * Detect platform from client info or environment. + * + * @param clientInfo - Optional client info object + * @returns Detected platform + */ +export function detectPlatform(clientInfo?: { name?: string; version?: string }): Platform { + if (!clientInfo) { + return 'generic'; + } + + const name = clientInfo.name?.toLowerCase() || ''; + + if (name.includes('openai') || name.includes('chatgpt')) { + return 'openai'; + } + + if (name.includes('claude') || name.includes('anthropic')) { + return 'claude'; + } + + return 'generic'; +} diff --git a/libs/uipack/src/preview/openai-preview.ts b/libs/uipack/src/preview/openai-preview.ts new file mode 100644 index 00000000..0600cddf --- /dev/null +++ b/libs/uipack/src/preview/openai-preview.ts @@ -0,0 +1,373 @@ +/** + * OpenAI Preview Handler + * + * Generates metadata for OpenAI ChatGPT platform. + * + * Discovery (tools/list): + * - Static/Hybrid: Return shell via resource:// URI + * - Inline: Return minimal loader in _meta + * + * Execution (tool/call): + * - Static: Empty _meta, data via window.openai.toolOutput + * - Hybrid: Component chunk in _meta['ui/component'] + * - Inline: Full HTML in _meta['ui/html'] + * + * @packageDocumentation + */ + +import type { + PreviewHandler, + DiscoveryPreviewOptions, + ExecutionPreviewOptions, + DiscoveryMeta, + ExecutionMeta, + OpenAIMetaFields, + BuilderMockData, +} from './types'; +import type { BuilderResult, StaticBuildResult, HybridBuildResult, InlineBuildResult } from '../build/builders/types'; +import { safeJsonForScript } from '../utils'; + +// ============================================ +// Shared CSP Configuration +// ============================================ + +/** + * Default CSP domains for OpenAI widgets. + * Extracted to avoid DRY violation across discovery handlers. + */ +const DEFAULT_WIDGET_CSP: OpenAIMetaFields['openai/widgetCSP'] = { + connect_domains: ['esm.sh', 'cdn.tailwindcss.com'], + resource_domains: ['esm.sh', 'cdn.tailwindcss.com', 'fonts.googleapis.com', 'fonts.gstatic.com'], +}; + +// ============================================ +// Data Injection Placeholders +// ============================================ + +/** + * Placeholder patterns for hybrid data injection. + * Using regex for more robust matching that handles whitespace variations. + */ +const DATA_PLACEHOLDERS = { + input: /window\.__mcpToolInput\s*=\s*\{\s*\};?/g, + output: /window\.__mcpToolOutput\s*=\s*\{\s*\};?/g, + structuredContent: /window\.__mcpStructuredContent\s*=\s*\{\s*\};?/g, +}; + +// ============================================ +// OpenAI Preview Handler +// ============================================ + +/** + * Preview handler for OpenAI ChatGPT platform. + * + * @example + * ```typescript + * const preview = new OpenAIPreview(); + * + * // For tools/list + * const discoveryMeta = preview.forDiscovery({ + * buildResult: hybridResult, + * toolName: 'get_weather', + * }); + * + * // For tool/call + * const executionMeta = preview.forExecution({ + * buildResult: hybridResult, + * input: { location: 'NYC' }, + * output: { temperature: 72 }, + * }); + * ``` + */ +export class OpenAIPreview implements PreviewHandler { + readonly platform = 'openai' as const; + + /** + * Generate metadata for tool discovery (tools/list). + */ + forDiscovery(options: DiscoveryPreviewOptions): DiscoveryMeta { + const { buildResult, toolName, description } = options; + + switch (buildResult.mode) { + case 'static': + return this.forDiscoveryStatic(buildResult, toolName, description); + + case 'hybrid': + return this.forDiscoveryHybrid(buildResult, toolName, description); + + case 'inline': + return this.forDiscoveryInline(buildResult, toolName, description); + + default: + throw new Error(`Unknown build mode: ${(buildResult as BuilderResult).mode}`); + } + } + + /** + * Generate metadata for tool execution (tool/call). + */ + forExecution(options: ExecutionPreviewOptions): ExecutionMeta { + const { buildResult, input, output, builderMode = false, mockData } = options; + + switch (buildResult.mode) { + case 'static': + return this.forExecutionStatic(buildResult, input, output, builderMode, mockData); + + case 'hybrid': + return this.forExecutionHybrid(buildResult, input, output, builderMode, mockData); + + case 'inline': + return this.forExecutionInline(buildResult, input, output, builderMode, mockData); + + default: + throw new Error(`Unknown build mode: ${(buildResult as BuilderResult).mode}`); + } + } + + // ============================================ + // Discovery Handlers + // ============================================ + + private forDiscoveryStatic(result: StaticBuildResult, toolName: string, _description?: string): DiscoveryMeta { + // Static mode: Return shell as resource + const resourceUri = `resource://widget/${toolName}`; + + const _meta: OpenAIMetaFields = { + 'openai/outputTemplate': resourceUri, + 'openai/widgetAccessible': true, + 'openai/resultCanProduceWidget': true, + 'openai/displayMode': 'inline', + 'openai/widgetCSP': DEFAULT_WIDGET_CSP, + }; + + return { + _meta: _meta as Record, + resourceUri, + resourceContent: result.html, + }; + } + + private forDiscoveryHybrid(result: HybridBuildResult, _toolName: string, _description?: string): DiscoveryMeta { + // Hybrid mode: Return vendor shell as resource + const _meta: OpenAIMetaFields = { + 'openai/outputTemplate': result.shellResourceUri, + 'openai/widgetAccessible': true, + 'openai/resultCanProduceWidget': true, + 'openai/displayMode': 'inline', + 'openai/widgetCSP': DEFAULT_WIDGET_CSP, + }; + + return { + _meta: _meta as Record, + resourceUri: result.shellResourceUri, + resourceContent: result.vendorShell, + }; + } + + private forDiscoveryInline(result: InlineBuildResult, _toolName: string, _description?: string): DiscoveryMeta { + // Inline mode: Return loader in _meta + const _meta: OpenAIMetaFields = { + 'openai/html': result.loaderShell, + 'openai/widgetAccessible': true, + 'openai/resultCanProduceWidget': true, + 'openai/displayMode': 'inline', + 'openai/widgetCSP': DEFAULT_WIDGET_CSP, + }; + + return { + _meta: _meta as Record, + }; + } + + // ============================================ + // Execution Handlers + // ============================================ + + private forExecutionStatic( + result: StaticBuildResult, + input: unknown, + output: unknown, + builderMode: boolean, + mockData?: BuilderMockData, + ): ExecutionMeta { + // Static mode: Data via window.openai.toolOutput (platform handles injection) + // We just return structuredContent + + if (builderMode) { + // For builder mode, inject full HTML with mock window.openai + const html = this.injectBuilderMode(result.html, input, output, mockData); + return { + _meta: { + 'openai/html': html, + }, + html, + structuredContent: output, + textContent: JSON.stringify(output, null, 2), + }; + } + + // Normal mode: Platform injects data via toolOutput + return { + _meta: {}, + structuredContent: output, + textContent: JSON.stringify(output, null, 2), + }; + } + + private forExecutionHybrid( + result: HybridBuildResult, + input: unknown, + output: unknown, + builderMode: boolean, + mockData?: BuilderMockData, + ): ExecutionMeta { + // Hybrid mode: Return component chunk + const _meta: OpenAIMetaFields = { + 'openai/component': result.componentChunk, + }; + + if (builderMode) { + // For builder mode, combine shell + component + mock data + // This simulates what the platform would do + const html = this.combineHybridForBuilder(result, input, output, mockData); + return { + _meta: { + 'openai/html': html, + }, + html, + structuredContent: output, + textContent: JSON.stringify(output, null, 2), + }; + } + + return { + _meta: _meta as Record, + structuredContent: output, + textContent: JSON.stringify(output, null, 2), + }; + } + + private forExecutionInline( + _result: InlineBuildResult, + _input: unknown, + output: unknown, + _builderMode: boolean, + _mockData?: BuilderMockData, + ): ExecutionMeta { + // Inline mode requires async buildFullWidget which this sync interface doesn't support. + // This is a known limitation - inline mode should use the async preview API or + // pre-build widgets at registration time. + // + // TODO: Consider making forExecution async or providing a separate async variant. + console.warn( + '[OpenAIPreview] Inline mode execution is not fully implemented. ' + + 'Use static or hybrid mode for production, or pre-build widgets.', + ); + + const _meta: OpenAIMetaFields = { + 'openai/html': '', + }; + + return { + _meta: _meta as Record, + structuredContent: output, + textContent: JSON.stringify(output, null, 2), + }; + } + + // ============================================ + // Builder Mode Helpers + // ============================================ + + /** + * Inject builder mode mock APIs into HTML. + * + * Uses safeJsonForScript to prevent XSS attacks from malicious data + * containing `` or other HTML-sensitive sequences. + */ + private injectBuilderMode(html: string, input: unknown, output: unknown, mockData?: BuilderMockData): string { + const theme = mockData?.theme || 'light'; + const displayMode = mockData?.displayMode || 'inline'; + const toolResponses = mockData?.toolResponses || {}; + + // Use safeJsonForScript to escape and other HTML-sensitive sequences + const safeTheme = safeJsonForScript(theme); + const safeDisplayMode = safeJsonForScript(displayMode); + const safeToolResponses = safeJsonForScript(toolResponses); + const safeInput = safeJsonForScript(input); + const safeOutput = safeJsonForScript(output); + + // Create mock window.openai object + const mockScript = ` + + `; + + // Inject after + return html.replace('', '\n' + mockScript); + } + + /** + * Combine hybrid shell + component for builder mode. + * + * Uses regex patterns for robust placeholder matching and + * safeJsonForScript to prevent XSS attacks. + */ + private combineHybridForBuilder( + result: HybridBuildResult, + input: unknown, + output: unknown, + mockData?: BuilderMockData, + ): string { + let html = result.vendorShell; + + // Use safeJsonForScript to escape and other HTML-sensitive sequences + const safeInput = safeJsonForScript(input); + const safeOutput = safeJsonForScript(output); + + // Inject data using regex for robust matching (handles whitespace variations) + html = html + .replace(DATA_PLACEHOLDERS.input, `window.__mcpToolInput = ${safeInput};`) + .replace(DATA_PLACEHOLDERS.output, `window.__mcpToolOutput = ${safeOutput};`) + .replace(DATA_PLACEHOLDERS.structuredContent, `window.__mcpStructuredContent = ${safeOutput};`); + + // Inject component and builder mode mock + const componentScript = ` + + `; + + html = html.replace('', componentScript + '\n'); + + // Add builder mode mock + return this.injectBuilderMode(html, input, output, mockData); + } +} diff --git a/libs/uipack/src/preview/types.ts b/libs/uipack/src/preview/types.ts new file mode 100644 index 00000000..9560b294 --- /dev/null +++ b/libs/uipack/src/preview/types.ts @@ -0,0 +1,233 @@ +/** + * Preview Types + * + * Type definitions for platform-specific preview handlers. + * + * @packageDocumentation + */ + +import type { BuilderResult } from '../build/builders/types'; + +// Re-export AIPlatformType from the canonical source for convenience +export type { AIPlatformType } from '../adapters/platform-meta'; + +// ============================================ +// Platform Types +// ============================================ + +/** + * Supported MCP platforms for preview handlers. + * + * This is a strict subset used for preview handler routing. + * - 'openai': OpenAI ChatGPT platform + * - 'claude': Anthropic Claude platform + * - 'generic': All other MCP clients + * + * For broader platform detection (including IDEs), see AIPlatformType + * from the adapters module, which is the canonical source for that type. + */ +export type Platform = 'openai' | 'claude' | 'generic'; + +// ============================================ +// Preview Options +// ============================================ + +/** + * Options for generating discovery metadata (tools/list). + */ +export interface DiscoveryPreviewOptions { + /** + * Build result from a builder. + */ + buildResult: BuilderResult; + + /** + * Name of the tool. + */ + toolName: string; + + /** + * Optional description for the widget. + */ + description?: string; +} + +/** + * Options for generating execution metadata (tool/call). + */ +export interface ExecutionPreviewOptions { + /** + * Build result from a builder. + */ + buildResult: BuilderResult; + + /** + * Tool input arguments. + */ + input: unknown; + + /** + * Tool output/result data. + */ + output: unknown; + + /** + * Whether to enable builder mode (inject mock platform APIs). + * @default false + */ + builderMode?: boolean; + + /** + * Mock data for builder mode. + */ + mockData?: BuilderMockData; +} + +/** + * Mock data for builder mode preview. + */ +export interface BuilderMockData { + /** + * Theme to use ('light' | 'dark'). + */ + theme?: 'light' | 'dark'; + + /** + * Display mode. + */ + displayMode?: 'inline' | 'immersive'; + + /** + * Mock tool call responses. + */ + toolResponses?: Record; +} + +// ============================================ +// Preview Results +// ============================================ + +/** + * Result from discovery preview (tools/list). + */ +export interface DiscoveryMeta { + /** + * Metadata fields for the tool response. + */ + _meta: Record; + + /** + * Resource URI (if static/hybrid mode with resource delivery). + */ + resourceUri?: string; + + /** + * Resource content (if resource needs to be registered). + */ + resourceContent?: string; +} + +/** + * Result from execution preview (tool/call). + */ +export interface ExecutionMeta { + /** + * Metadata fields for the tool response. + */ + _meta: Record; + + /** + * Complete HTML (for inline delivery). + */ + html?: string; + + /** + * Structured content for the response. + */ + structuredContent?: unknown; + + /** + * Text content for fallback display. + */ + textContent?: string; +} + +// ============================================ +// Preview Handler Interface +// ============================================ + +/** + * Interface for platform-specific preview handlers. + */ +export interface PreviewHandler { + /** + * Platform this handler is for. + */ + readonly platform: Platform; + + /** + * Generate metadata for tool discovery (tools/list). + * + * @param options - Discovery options + * @returns Discovery metadata + */ + forDiscovery(options: DiscoveryPreviewOptions): DiscoveryMeta; + + /** + * Generate metadata for tool execution (tool/call). + * + * @param options - Execution options + * @returns Execution metadata + */ + forExecution(options: ExecutionPreviewOptions): ExecutionMeta; +} + +// ============================================ +// Meta Field Types +// ============================================ + +/** + * OpenAI-specific metadata fields. + */ +export interface OpenAIMetaFields { + 'openai/outputTemplate'?: string; + 'openai/html'?: string; + 'openai/component'?: string; + 'openai/widgetCSP'?: { + connect_domains?: string[]; + resource_domains?: string[]; + }; + 'openai/widgetAccessible'?: boolean; + 'openai/displayMode'?: 'inline' | 'immersive'; + 'openai/resultCanProduceWidget'?: boolean; +} + +/** + * Claude-specific metadata fields. + */ +export interface ClaudeMetaFields { + 'ui/html'?: string; + 'ui/mimeType'?: string; + 'claude/widgetDescription'?: string; + 'claude/prefersBorder'?: boolean; +} + +/** + * FrontMCP/Generic metadata fields. + */ +export interface FrontMCPMetaFields { + 'frontmcp/html'?: string; + 'frontmcp/outputTemplate'?: string; + 'frontmcp/component'?: string; + 'frontmcp/widgetCSP'?: { + connect_domains?: string[]; + resource_domains?: string[]; + }; + 'ui/html'?: string; + 'ui/mimeType'?: string; +} + +/** + * Combined metadata fields for all platforms. + */ +export type UIMetaFields = OpenAIMetaFields & ClaudeMetaFields & FrontMCPMetaFields; diff --git a/libs/uipack/src/registry/render-template.ts b/libs/uipack/src/registry/render-template.ts index ac9d032a..7570a636 100644 --- a/libs/uipack/src/registry/render-template.ts +++ b/libs/uipack/src/registry/render-template.ts @@ -67,7 +67,7 @@ export function containsMdxSyntax(source: string): boolean { /** * Render MDX content to HTML string. * - * Uses the MDX renderer from @frontmcp/ui. + * Uses the MDX client renderer from the local renderers module. * Falls back to plain text if MDX rendering is not available. */ async function renderMdxContent( @@ -77,16 +77,16 @@ async function renderMdxContent( mdxComponents?: Record, ): Promise { try { - // Import the MDX renderer from renderers module - const { mdxRenderer } = await import('../renderers/index.js'); + // Import the MDX client renderer from renderers module + const { mdxClientRenderer } = await import('../renderers/index.js'); // Render MDX to HTML with custom components - const html = await mdxRenderer.render(mdxContent, context, { mdxComponents }); + const html = await mdxClientRenderer.render(mdxContent, context, { mdxComponents }); return html; } catch (error) { // If MDX rendering fails, warn and return escaped content console.error( - '[@frontmcp/ui] MDX rendering failed:', + '[@frontmcp/uipack] MDX rendering failed:', error instanceof Error ? error.stack || error.message : String(error), ); diff --git a/libs/uipack/src/renderers/index.ts b/libs/uipack/src/renderers/index.ts index 5bf624d5..7be806ea 100644 --- a/libs/uipack/src/renderers/index.ts +++ b/libs/uipack/src/renderers/index.ts @@ -49,7 +49,7 @@ export type { } from './types'; // Cache -export { TranspileCache, transpileCache, renderCache, componentCache, type TranspileCacheOptions } from './cache'; +export { TranspileCache, transpileCache, componentCache, type TranspileCacheOptions } from './cache'; // Registry export { RendererRegistry, rendererRegistry } from './registry'; @@ -67,22 +67,9 @@ export { type MdxClientCdnConfig, } from './mdx-client.renderer'; -/** - * @deprecated The `MdxRenderer` export is deprecated and will be removed in v1.0.0. - * - * For server-side MDX rendering with React, use `@frontmcp/ui/renderers`: - * ```typescript - * import { MdxRenderer, mdxRenderer } from '@frontmcp/ui/renderers'; - * ``` - * - * For client-side CDN-based MDX rendering (no React bundled), use: - * ```typescript - * import { MdxClientRenderer, mdxClientRenderer } from '@frontmcp/uipack/renderers'; - * ``` - */ -export { MdxClientRenderer as MdxRenderer, mdxClientRenderer as mdxRenderer } from './mdx-client.renderer'; - // Note: React renderer and server-side MDX are in @frontmcp/ui package (requires React) +// For server-side MDX rendering with React, use: +// import { MdxRenderer, mdxRenderer } from '@frontmcp/ui/renderers'; // Utilities export { diff --git a/libs/uipack/src/renderers/registry.ts b/libs/uipack/src/renderers/registry.ts index 8132678d..59440de8 100644 --- a/libs/uipack/src/renderers/registry.ts +++ b/libs/uipack/src/renderers/registry.ts @@ -270,11 +270,11 @@ export class RendererRegistry { * React and MDX renderers can be added: * * ```typescript - * import { rendererRegistry, mdxRenderer } from '@frontmcp/uipack/renderers'; + * import { rendererRegistry, mdxClientRenderer } from '@frontmcp/uipack/renderers'; * import { reactRenderer } from '@frontmcp/ui'; * * rendererRegistry.register(reactRenderer); - * rendererRegistry.register(mdxRenderer); + * rendererRegistry.register(mdxClientRenderer); * ``` */ export const rendererRegistry = new RendererRegistry(); diff --git a/libs/uipack/src/styles/variants.ts b/libs/uipack/src/styles/variants.ts index cb9fc51f..609c0734 100644 --- a/libs/uipack/src/styles/variants.ts +++ b/libs/uipack/src/styles/variants.ts @@ -104,7 +104,7 @@ export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'; export const BUTTON_VARIANTS: Record = { primary: 'bg-primary hover:bg-primary/90 text-white shadow-sm', secondary: 'bg-secondary hover:bg-secondary/90 text-white shadow-sm', - outline: 'border-2 border-primary bg-primary hover:bg-primary/90', + outline: 'border-2 border-primary text-primary bg-transparent hover:bg-primary/10', ghost: 'text-text-primary hover:bg-gray-100', danger: 'bg-danger hover:bg-danger/90 text-white shadow-sm', success: 'bg-success hover:bg-success/90 text-white shadow-sm', diff --git a/libs/uipack/src/utils/__tests__/escape-html.test.ts b/libs/uipack/src/utils/__tests__/escape-html.test.ts index a0f8372b..acb3c6db 100644 --- a/libs/uipack/src/utils/__tests__/escape-html.test.ts +++ b/libs/uipack/src/utils/__tests__/escape-html.test.ts @@ -4,7 +4,7 @@ * Tests for HTML escaping functions to prevent XSS. */ -import { escapeHtml, escapeHtmlAttr, escapeJsString } from '../escape-html'; +import { escapeHtml, escapeHtmlAttr, escapeJsString, escapeScriptClose, safeJsonForScript } from '../escape-html'; describe('escapeHtml', () => { describe('null and undefined handling', () => { @@ -122,3 +122,121 @@ describe('escapeJsString', () => { expect(escapeJsString('line\u2028sep\u2029')).toBe('line\\u2028sep\\u2029'); }); }); + +describe('escapeScriptClose', () => { + it('should escape closing tags', () => { + const json = JSON.stringify({ html: '' }); + expect(escapeScriptClose(json)).toBe('{"html":"<\\/script>"}'); + }); + + it('should escape multiple closing tags', () => { + const json = JSON.stringify({ html: '' }); + expect(escapeScriptClose(json)).toBe('{"html":"<\\/script> in values', () => { + const result = safeJsonForScript({ html: '' }); + expect(result).toContain('<\\/script>'); + expect(result).not.toContain(''); + }); + + it('should handle nested objects with script tags', () => { + const result = safeJsonForScript({ + outer: { + inner: '', + }, + }); + expect(result).toBe('{"outer":{"inner":"<\\/script>"}}'); + }); + + it('should handle arrays with script tags', () => { + const result = safeJsonForScript(['', '' }; + const result = safeJsonForScript(malicious); + + // The result should not contain unescaped + expect(result).not.toMatch(/<\/script>/i); + + // It should be valid JSON when the escape is reversed + const unescaped = result.replace(/<\\\//g, ' JSON.parse(unescaped)).not.toThrow(); + expect(JSON.parse(unescaped)).toEqual(malicious); + }); +}); diff --git a/libs/uipack/src/utils/escape-html.ts b/libs/uipack/src/utils/escape-html.ts index 9d188143..7ed2ee34 100644 --- a/libs/uipack/src/utils/escape-html.ts +++ b/libs/uipack/src/utils/escape-html.ts @@ -105,3 +105,55 @@ export function escapeJsString(str: string): string { export function escapeScriptClose(jsonString: string): string { return jsonString.replace(/<\//g, '<\\/'); } + +/** + * Safely serialize a value to JSON for embedding in HTML ` or other HTML-sensitive sequences. + * + * Handles edge cases: + * - undefined: Returns 'null' (since undefined is not valid JSON) + * - Circular references: Returns error placeholder + * - BigInt: Converted to string representation + * - Functions/Symbols: Omitted (standard JSON.stringify behavior) + * + * @param value - Value to serialize + * @returns Escaped JSON string safe for embedding in script tags + * + * @example + * ```typescript + * const data = { html: '' }; + * const safe = safeJsonForScript(data); + * // Returns: '{"html":"<\\/script> + * ``` + */ +export function safeJsonForScript(value: unknown): string { + // Handle undefined explicitly since JSON.stringify(undefined) returns undefined + if (value === undefined) { + return 'null'; + } + + try { + // Use a replacer to handle BigInt values + const jsonString = JSON.stringify(value, (_key, val) => { + if (typeof val === 'bigint') { + return val.toString(); + } + return val; + }); + + // JSON.stringify can return undefined for some edge cases (pure symbol, function) + if (jsonString === undefined) { + return 'null'; + } + + return escapeScriptClose(jsonString); + } catch { + // Handle circular references and other stringify errors + return '{"error":"Value could not be serialized"}'; + } +} diff --git a/libs/uipack/src/utils/index.ts b/libs/uipack/src/utils/index.ts index 8f29d8ee..81de1cca 100644 --- a/libs/uipack/src/utils/index.ts +++ b/libs/uipack/src/utils/index.ts @@ -7,4 +7,4 @@ */ export { safeStringify } from './safe-stringify'; -export { escapeHtml, escapeHtmlAttr, escapeJsString, escapeScriptClose } from './escape-html'; +export { escapeHtml, escapeHtmlAttr, escapeJsString, escapeScriptClose, safeJsonForScript } from './escape-html';