From 9234f7815114418c27a8beb40c48587f2c1d5981 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Thu, 2 Jul 2026 13:57:51 +0200 Subject: [PATCH 1/6] feat: Add custom view commands (dataset views, trace views) --- scripts/views-runner.ts | 518 ++++++++++ scripts/views-sdk.ts | 249 +++++ src/datasets/api.rs | 42 + src/main.rs | 7 + src/views.rs | 2007 +++++++++++++++++++++++++++++++++++++++ tests/cli.rs | 107 +++ 6 files changed, 2930 insertions(+) create mode 100644 scripts/views-runner.ts create mode 100644 scripts/views-sdk.ts create mode 100644 src/views.rs diff --git a/scripts/views-runner.ts b/scripts/views-runner.ts new file mode 100644 index 0000000..712f4bc --- /dev/null +++ b/scripts/views-runner.ts @@ -0,0 +1,518 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { pathToFileURL } from "node:url"; + +type EsbuildModule = { + build: (options: Record) => Promise; +}; + +type JsonValue = + | string + | number + | boolean + | null + | JsonValue[] + | { [key: string]: JsonValue }; + +type RegisteredCustomView = { + kind?: string; + name?: string; + slug?: string; + description?: string; + project?: string | { id?: string; name?: string }; + dataset?: { id?: string; name?: string }; + tags?: unknown; + metadata?: unknown; +}; + +type ManifestEntry = { + kind: "view"; + view_type: "trace" | "dataset"; + name: string; + slug: string; + code: string; + description?: string; + project_id?: string; + project_name?: string; + dataset_id?: string; + dataset_name?: string; + tags?: string[]; + metadata?: JsonValue; +}; + +type Manifest = { + runtime_context: { + runtime: "browser"; + version: "latest"; + }; + files: Array<{ + source_file: string; + entries: ManifestEntry[]; + }>; +}; + +const __filename = path.resolve(process.argv[1] || "views-runner.ts"); +const __dirname = path.dirname(__filename); + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function normalizeEsbuildModule(value: unknown): EsbuildModule | null { + if (isObject(value) && typeof value.build === "function") { + return value as EsbuildModule; + } + if ( + isObject(value) && + isObject(value.default) && + typeof value.default.build === "function" + ) { + return value.default as EsbuildModule; + } + return null; +} + +async function loadEsbuild(sourceFiles: string[]): Promise { + const candidates = [ + ...sourceFiles.map((file) => path.resolve(file)), + path.join(process.cwd(), "package.json"), + path.join(__dirname, "package.json"), + ]; + + for (const candidate of candidates) { + try { + const requireFrom = createRequire(pathToFileURL(candidate).href); + const resolved = requireFrom.resolve("esbuild"); + const loaded = requireFrom(resolved); + const normalized = normalizeEsbuildModule(loaded); + if (normalized) { + return normalized; + } + } catch { + // Try the next search root. + } + } + + try { + const esbuildSpecifier = "esbuild"; + const loaded = await import(esbuildSpecifier); + const normalized = normalizeEsbuildModule(loaded); + if (normalized) { + return normalized; + } + } catch { + // handled below + } + + throw new Error( + "failed to load esbuild for custom view bundling; install esbuild in your project", + ); +} + +function loadTsconfigPath(): string | undefined { + const tsNode = process.env.TS_NODE_PROJECT?.trim(); + if (tsNode) { + return tsNode; + } + const tsx = process.env.TSX_TSCONFIG_PATH?.trim(); + if (tsx) { + return tsx; + } + return undefined; +} + +function sdkPath(): string { + return process.env.BT_VIEWS_SDK_PATH || path.join(__dirname, "views-sdk.ts"); +} + +function reactModuleSource(): string { + return ` +const ReactValue = globalThis.React || React; +export default ReactValue; +export const Children = ReactValue.Children; +export const Component = ReactValue.Component; +export const Fragment = ReactValue.Fragment; +export const Profiler = ReactValue.Profiler; +export const PureComponent = ReactValue.PureComponent; +export const StrictMode = ReactValue.StrictMode; +export const Suspense = ReactValue.Suspense; +export const cloneElement = ReactValue.cloneElement; +export const createContext = ReactValue.createContext; +export const createElement = ReactValue.createElement; +export const createRef = ReactValue.createRef; +export const forwardRef = ReactValue.forwardRef; +export const isValidElement = ReactValue.isValidElement; +export const lazy = ReactValue.lazy; +export const memo = ReactValue.memo; +export const startTransition = ReactValue.startTransition; +export const useCallback = ReactValue.useCallback; +export const useContext = ReactValue.useContext; +export const useDebugValue = ReactValue.useDebugValue; +export const useDeferredValue = ReactValue.useDeferredValue; +export const useEffect = ReactValue.useEffect; +export const useId = ReactValue.useId; +export const useImperativeHandle = ReactValue.useImperativeHandle; +export const useInsertionEffect = ReactValue.useInsertionEffect; +export const useLayoutEffect = ReactValue.useLayoutEffect; +export const useMemo = ReactValue.useMemo; +export const useReducer = ReactValue.useReducer; +export const useRef = ReactValue.useRef; +export const useState = ReactValue.useState; +export const useSyncExternalStore = ReactValue.useSyncExternalStore; +export const useTransition = ReactValue.useTransition; +`; +} + +function jsxRuntimeSource(): string { + return ` +const ReactValue = globalThis.React || React; +export const Fragment = ReactValue.Fragment; +export const jsx = ReactValue.createElement; +export const jsxs = ReactValue.createElement; +export const jsxDEV = ReactValue.createElement; +`; +} + +function installDiscoveryReactStub(): void { + const globalWithReact = globalThis as { + React?: Record; + }; + if (globalWithReact.React) { + return; + } + const createElement = (...args: unknown[]) => ({ + type: args[0], + props: args[1] ?? {}, + children: args.slice(2), + }); + globalWithReact.React = { + Children: {}, + Component: class {}, + Fragment: Symbol.for("react.fragment"), + Profiler: Symbol.for("react.profiler"), + PureComponent: class {}, + StrictMode: Symbol.for("react.strict_mode"), + Suspense: Symbol.for("react.suspense"), + cloneElement: createElement, + createContext: () => ({}), + createElement, + createRef: () => ({ current: null }), + forwardRef: (component: unknown) => component, + isValidElement: (value: unknown) => + typeof value === "object" && value !== null, + lazy: (loader: unknown) => loader, + memo: (component: unknown) => component, + startTransition: (callback: () => void) => callback(), + useCallback: (callback: unknown) => callback, + useContext: () => undefined, + useDebugValue: () => undefined, + useDeferredValue: (value: unknown) => value, + useEffect: () => undefined, + useId: () => "bt-view-preview-id", + useImperativeHandle: () => undefined, + useInsertionEffect: () => undefined, + useLayoutEffect: () => undefined, + useMemo: (factory: () => unknown) => factory(), + useReducer: (_reducer: unknown, initialValue: unknown) => [ + initialValue, + () => undefined, + ], + useRef: (current: unknown) => ({ current }), + useState: (initialValue: unknown) => [initialValue, () => undefined], + useSyncExternalStore: () => undefined, + useTransition: () => [false, () => undefined], + }; +} + +function braintrustViewPlugin() { + const helperPath = sdkPath(); + return { + name: "braintrust-custom-view-local-sdk", + setup(build: { + onResolve: ( + options: { filter: RegExp }, + callback: (args: { path: string }) => unknown, + ) => void; + onLoad: ( + options: { filter: RegExp; namespace?: string }, + callback: (args: { path: string }) => unknown, + ) => void; + }) { + build.onResolve({ filter: /^braintrust\/custom-views$/ }, () => ({ + path: helperPath, + })); + build.onResolve({ filter: /^@braintrust\/local\/custom-views$/ }, () => ({ + path: helperPath, + })); + build.onResolve({ filter: /^react$/ }, () => ({ + path: "react", + namespace: "bt-view-react", + })); + build.onResolve({ filter: /^react\/jsx-runtime$/ }, () => ({ + path: "react/jsx-runtime", + namespace: "bt-view-react", + })); + build.onResolve({ filter: /^react\/jsx-dev-runtime$/ }, () => ({ + path: "react/jsx-dev-runtime", + namespace: "bt-view-react", + })); + build.onLoad( + { filter: /^react$/, namespace: "bt-view-react" }, + () => ({ + contents: reactModuleSource(), + loader: "js", + }), + ); + build.onLoad( + { filter: /^react\/jsx-(dev-)?runtime$/, namespace: "bt-view-react" }, + () => ({ + contents: jsxRuntimeSource(), + loader: "js", + }), + ); + }, + }; +} + +function validateJson(value: unknown, label: string): JsonValue | undefined { + if (value === undefined) { + return undefined; + } + try { + return JSON.parse(JSON.stringify(value)) as JsonValue; + } catch { + throw new Error(`${label} must be JSON-serializable`); + } +} + +function normalizeTags(value: unknown, slug: string): string[] | undefined { + if (value === undefined) { + return undefined; + } + if (!Array.isArray(value) || !value.every((tag) => typeof tag === "string")) { + throw new Error(`custom view '${slug}' tags must be an array of strings`); + } + return value; +} + +function projectFields( + project: RegisteredCustomView["project"], +): Pick { + if (project === undefined) { + return {}; + } + if (typeof project === "string") { + return { project_name: project }; + } + if (typeof project.id === "string" && project.id.trim()) { + return { project_id: project.id }; + } + if (typeof project.name === "string" && project.name.trim()) { + return { project_name: project.name }; + } + throw new Error("custom view project must be a string, { id }, or { name }"); +} + +function datasetFields( + view: RegisteredCustomView, +): Pick { + if (view.kind !== "dataset") { + return {}; + } + const dataset = view.dataset; + if (!dataset) { + throw new Error(`dataset custom view '${view.slug}' requires dataset`); + } + if (typeof dataset.id === "string" && dataset.id.trim()) { + return { dataset_id: dataset.id }; + } + if (typeof dataset.name === "string" && dataset.name.trim()) { + return { dataset_name: dataset.name }; + } + throw new Error( + `dataset custom view '${view.slug}' dataset must be { id } or { name }`, + ); +} + +function validateRegisteredView(view: RegisteredCustomView): void { + if (view.kind !== "trace" && view.kind !== "dataset") { + throw new Error("custom view kind must be 'trace' or 'dataset'"); + } + if (typeof view.name !== "string" || !view.name.trim()) { + throw new Error("custom view name is required"); + } + if (typeof view.slug !== "string" || !view.slug.trim()) { + throw new Error(`custom view '${view.name}' slug is required`); + } +} + +async function bundleForDiscovery( + esbuild: EsbuildModule, + sourceFile: string, + outputFile: string, + tsconfig: string | undefined, +): Promise { + await esbuild.build({ + entryPoints: [sourceFile], + outfile: outputFile, + bundle: true, + platform: "node", + target: "node18", + format: "cjs", + tsconfig, + write: true, + plugins: [braintrustViewPlugin()], + }); +} + +async function collectViews( + esbuild: EsbuildModule, + sourceFile: string, + tempDir: string, + tsconfig: string | undefined, +): Promise { + (globalThis as { __braintrust_custom_views?: RegisteredCustomView[] }).__braintrust_custom_views = []; + installDiscoveryReactStub(); + const outputFile = path.join( + tempDir, + `${path.basename(sourceFile).replace(/[^\w.-]/g, "_")}.collector.cjs`, + ); + await bundleForDiscovery(esbuild, sourceFile, outputFile, tsconfig); + await import(`${pathToFileURL(outputFile).href}?bt_view_nonce=${Date.now()}`); + const views = + (globalThis as { __braintrust_custom_views?: RegisteredCustomView[] }) + .__braintrust_custom_views ?? []; + const seen = new Set(); + for (const view of views) { + validateRegisteredView(view); + const key = `${view.kind}:${view.slug}`; + if (seen.has(key)) { + throw new Error(`duplicate custom view slug '${view.slug}' in ${sourceFile}`); + } + seen.add(key); + } + return [...views]; +} + +function virtualEntrySource(sourceFile: string, slug: string): string { + return ` +import ${JSON.stringify(sourceFile)}; +import { __getCustomViews } from "braintrust/custom-views"; +const view = [...__getCustomViews()].reverse().find((candidate) => candidate.slug === ${JSON.stringify(slug)}); +if (!view) { + throw new Error("Custom view not registered: ${slug.replaceAll('"', '\\"')}"); +} +export default view.component; +`; +} + +async function bundleBrowserCode( + esbuild: EsbuildModule, + sourceFile: string, + view: RegisteredCustomView, + tempDir: string, + tsconfig: string | undefined, +): Promise { + const entryFile = path.join( + tempDir, + `${view.slug?.replace(/[^\w.-]/g, "_") || "view"}.entry.ts`, + ); + const outputFile = path.join( + tempDir, + `${view.slug?.replace(/[^\w.-]/g, "_") || "view"}.browser.js`, + ); + fs.writeFileSync(entryFile, virtualEntrySource(sourceFile, view.slug!), "utf8"); + + await esbuild.build({ + entryPoints: [entryFile], + outfile: outputFile, + bundle: true, + platform: "browser", + target: "es2019", + format: "iife", + globalName: "__BraintrustCustomView", + tsconfig, + write: true, + treeShaking: true, + jsxFactory: "React.createElement", + jsxFragment: "React.Fragment", + footer: { + js: "module.exports = __BraintrustCustomView;", + }, + plugins: [braintrustViewPlugin()], + }); + + return fs.readFileSync(outputFile, "utf8"); +} + +async function buildManifest(files: string[]): Promise { + const sourceFiles = files.map((file) => path.resolve(file)); + const esbuild = await loadEsbuild(sourceFiles); + const tsconfig = loadTsconfigPath(); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "bt-views-")); + + try { + const manifest: Manifest = { + runtime_context: { + runtime: "browser", + version: "latest", + }, + files: [], + }; + + for (const sourceFile of sourceFiles) { + if (!fs.existsSync(sourceFile)) { + throw new Error(`custom view file not found: ${sourceFile}`); + } + const views = await collectViews(esbuild, sourceFile, tempDir, tsconfig); + const entries: ManifestEntry[] = []; + for (const view of views) { + const code = await bundleBrowserCode( + esbuild, + sourceFile, + view, + tempDir, + tsconfig, + ); + entries.push({ + kind: "view", + view_type: view.kind as "trace" | "dataset", + name: view.name!, + slug: view.slug!, + description: view.description, + code, + ...projectFields(view.project), + ...datasetFields(view), + tags: normalizeTags(view.tags, view.slug!), + metadata: validateJson(view.metadata, `custom view '${view.slug}' metadata`), + }); + } + manifest.files.push({ source_file: sourceFile, entries }); + } + + return manifest; + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +async function main(): Promise { + const files = process.argv.slice(2); + if (files.length === 0) { + throw new Error("views-runner requires at least one view file"); + } + + const manifest = await buildManifest(files); + process.stdout.write(`${JSON.stringify(manifest)}\n`); +} + +main().catch((error: unknown) => { + const message = + error instanceof Error + ? error.message + : `failed to build custom view manifest: ${String(error)}`; + process.stderr.write(`${message}\n`); + process.exitCode = 1; +}); diff --git a/scripts/views-sdk.ts b/scripts/views-sdk.ts new file mode 100644 index 0000000..173203c --- /dev/null +++ b/scripts/views-sdk.ts @@ -0,0 +1,249 @@ +export type JsonValue = + | string + | number + | boolean + | null + | JsonValue[] + | { [key: string]: JsonValue }; + +export type JsonObject = { [key: string]: JsonValue }; + +export type InlineAttachment = { + type: "inline_attachment"; + src: string; + content_type: string; + filename?: string; + data?: unknown; +}; + +export type SpanFieldName = "input" | "output" | "expected" | "metadata"; + +export type SpanFields = { + input?: unknown; + output?: unknown; + expected?: unknown; + metadata?: Record; +}; + +export type TraceViewSpan< + TInput = unknown, + TOutput = unknown, + TExpected = unknown, + TMetadata extends Record = Record, +> = { + id: string; + span_id: string; + root_span_id: string; + parent_span_id?: string | null; + span_parents?: string[]; + data: { + input?: TInput; + output?: TOutput; + expected?: TExpected; + metadata?: TMetadata; + scores?: Record; + metrics?: Record; + error?: string; + tags?: string[]; + span_attributes?: Record; + [key: string]: unknown; + }; + children: string[]; +}; + +export type TraceViewTrace = { + rootSpanId: string; + selectedSpanId: string; + spanOrder: string[]; + spans: Record; + fetchSpanFields: ( + spanIds: string | string[], + fields?: SpanFieldName[], + ) => Promise>; +}; + +export type TraceViewUpdate< + TMetadata extends Record = Record, +> = { + (field: string, value: unknown): void; + (patch: Partial<{ metadata: TMetadata; tags: string[] }>): void; +}; + +export type TraceViewProps< + TInput = unknown, + TOutput = unknown, + TExpected = unknown, + TMetadata extends Record = Record, +> = { + trace: TraceViewTrace; + span: TraceViewSpan; + update: TraceViewUpdate; + selectSpan: (spanId: string) => void; +}; + +export type DatasetViewProps< + TInput = unknown, + TExpected = unknown, + TMetadata extends Record = Record, +> = { + id: string; + input: TInput; + expected: TExpected; + metadata: TMetadata; + tags: string[]; +}; + +export type ReactComponent = (props: Props) => unknown; + +type ProjectRef = string | { id: string } | { name: string }; +type DatasetRef = { id: string } | { name: string }; + +export type CustomTraceViewDefinition< + TInput = unknown, + TOutput = unknown, + TExpected = unknown, + TMetadata extends Record = Record, +> = { + name: string; + slug: string; + component: ReactComponent< + TraceViewProps + >; + description?: string; + project?: ProjectRef; + tags?: string[]; + metadata?: JsonObject; +}; + +export type CustomDatasetViewDefinition< + TInput = unknown, + TExpected = unknown, + TMetadata extends Record = Record, +> = { + name: string; + slug: string; + dataset: DatasetRef; + component: ReactComponent>; + description?: string; + project?: ProjectRef; + tags?: string[]; + metadata?: JsonObject; +}; + +export type RegisteredCustomView = { + kind: "trace" | "dataset"; + name: string; + slug: string; + component: ReactComponent; + description?: string; + project?: ProjectRef; + dataset?: DatasetRef; + tags?: string[]; + metadata?: JsonObject; +}; + +declare global { + // eslint-disable-next-line no-var + var __braintrust_custom_views: RegisteredCustomView[] | undefined; +} + +function registry(): RegisteredCustomView[] { + const current = globalThis.__braintrust_custom_views; + if (Array.isArray(current)) { + return current; + } + globalThis.__braintrust_custom_views = []; + return globalThis.__braintrust_custom_views; +} + +function registerView(view: RegisteredCustomView): void { + const views = registry(); + const existing = views.findIndex( + (candidate) => + candidate.kind === view.kind && candidate.slug === view.slug, + ); + if (existing >= 0) { + views.splice(existing, 1); + } + views.push(view); +} + +export function __getCustomViews(): RegisteredCustomView[] { + return [...registry()]; +} + +export function __clearCustomViews(): void { + registry().splice(0); +} + +export function customTraceView< + TInput = unknown, + TOutput = unknown, + TExpected = unknown, + TMetadata extends Record = Record, +>( + definition: CustomTraceViewDefinition< + TInput, + TOutput, + TExpected, + TMetadata + >, +): ReactComponent> { + registerView({ + kind: "trace", + name: definition.name, + slug: definition.slug, + component: definition.component, + description: definition.description, + project: definition.project, + tags: definition.tags, + metadata: definition.metadata, + }); + return definition.component; +} + +export function customDatasetView< + TInput = unknown, + TExpected = unknown, + TMetadata extends Record = Record, +>( + definition: CustomDatasetViewDefinition, +): ReactComponent> { + registerView({ + kind: "dataset", + name: definition.name, + slug: definition.slug, + component: definition.component, + description: definition.description, + project: definition.project, + dataset: definition.dataset, + tags: definition.tags, + metadata: definition.metadata, + }); + return definition.component; +} + +export function isInlineAttachment(value: unknown): value is InlineAttachment { + return ( + typeof value === "object" && + value !== null && + (value as { type?: unknown }).type === "inline_attachment" && + typeof (value as { src?: unknown }).src === "string" && + typeof (value as { content_type?: unknown }).content_type === "string" + ); +} + +export function parseMaybeJson(value: unknown): unknown { + if (typeof value !== "string") { + return value; + } + try { + return JSON.parse(value); + } catch { + return value; + } +} + +export function formatJson(value: unknown): string { + return JSON.stringify(value, null, 2); +} diff --git a/src/datasets/api.rs b/src/datasets/api.rs index f07532d..119dea4 100644 --- a/src/datasets/api.rs +++ b/src/datasets/api.rs @@ -230,6 +230,16 @@ pub async fn list_dataset_rows_limited( Ok((rows, truncated)) } +pub async fn get_dataset_row_by_id( + client: &ApiClient, + dataset_id: &str, + row_id: &str, +) -> Result> { + let query = build_dataset_row_by_id_query(dataset_id, row_id, DatasetRowsPreviewLength::Full); + let response = client.btql_structured::(&query).await?; + Ok(response.data.into_iter().next()) +} + pub async fn create_dataset( client: &ApiClient, project_id: &str, @@ -401,6 +411,38 @@ fn build_dataset_rows_query( query } +fn build_dataset_row_by_id_query( + dataset_id: &str, + row_id: &str, + preview_length: DatasetRowsPreviewLength, +) -> Value { + json!({ + "select": dataset_rows_select_fields(), + "from": { + "op": "function", + "name": {"op": "ident", "name": ["dataset"]}, + "args": [{"op": "literal", "value": dataset_id}] + }, + "filter": { + "op": "and", + "children": [ + { + "op": "ge", + "left": {"op": "ident", "name": ["created"]}, + "right": {"op": "literal", "value": DATASET_ROWS_SINCE} + }, + { + "op": "eq", + "left": {"op": "ident", "name": ["id"]}, + "right": {"op": "literal", "value": row_id} + } + ] + }, + "preview_length": preview_length.btql_value(), + "limit": 1 + }) +} + fn dataset_rows_select_fields() -> Vec { DATASET_RECORD_FIELDS .iter() diff --git a/src/main.rs b/src/main.rs index 676e79e..52f2b6b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,6 +34,7 @@ mod traces; mod ui; mod util_cmd; mod utils; +mod views; use crate::args::{has_explicit_profile_arg, ArgValueSource, BaseArgs, CLIArgs}; @@ -60,6 +61,7 @@ Core auth Authenticate bt with Braintrust switch Switch org and project context view View logs, traces, and spans + views Push and preview custom views Projects & resources projects Manage projects @@ -133,6 +135,8 @@ enum Commands { Auth(CLIArgs), /// View logs, traces, and spans View(CLIArgs), + /// Push and preview custom views + Views(CLIArgs), #[cfg(unix)] /// Run eval files Eval(CLIArgs), @@ -176,6 +180,7 @@ impl Commands { Commands::Sql(cmd) => &cmd.base, Commands::Auth(cmd) => &cmd.base, Commands::View(cmd) => &cmd.base, + Commands::Views(cmd) => &cmd.base, #[cfg(unix)] Commands::Eval(cmd) => &cmd.base, Commands::Projects(cmd) => &cmd.base, @@ -202,6 +207,7 @@ impl Commands { Commands::Sql(cmd) => &mut cmd.base, Commands::Auth(cmd) => &mut cmd.base, Commands::View(cmd) => &mut cmd.base, + Commands::Views(cmd) => &mut cmd.base, #[cfg(unix)] Commands::Eval(cmd) => &mut cmd.base, Commands::Projects(cmd) => &mut cmd.base, @@ -305,6 +311,7 @@ fn try_main() -> Result<()> { match cli.command { Commands::Auth(cmd) => auth::run(cmd.base, cmd.args).await?, Commands::View(cmd) => traces::run(cmd.base, cmd.args).await?, + Commands::Views(cmd) => views::run(cmd.base, cmd.args).await?, Commands::Init(cmd) => init::run(cmd.base, cmd.args).await?, Commands::Sql(cmd) => sql::run(cmd.base, cmd.args).await?, Commands::Setup(cmd) => setup::run_setup_top(cmd.base, cmd.args).await?, diff --git a/src/views.rs b/src/views.rs new file mode 100644 index 0000000..9390a2a --- /dev/null +++ b/src/views.rs @@ -0,0 +1,2007 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt::Write as _; +use std::net::TcpListener; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use actix_web::{web, App, HttpResponse, HttpServer}; +use anyhow::{anyhow, bail, Context, Result}; +use clap::{builder::BoolishValueParser, Args, Subcommand}; +use reqwest::Url; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; +use urlencoding::encode; + +use crate::args::BaseArgs; +use crate::auth; +use crate::datasets::api as datasets_api; +use crate::functions::{self, api as functions_api, IfExistsMode}; +use crate::http::ApiClient; +use crate::js_runner; +use crate::project_context::resolve_required_project; +use crate::projects::api::{get_project_by_name, list_projects, Project}; +use crate::ui::{self, with_spinner}; +use crate::utils::{app_project_url, app_project_url_with_encoded_path}; + +const VIEWS_JS_RUNNER_FILE: &str = "views-runner.ts"; +const VIEWS_JS_SDK_FILE: &str = "views-sdk.ts"; +const VIEWS_JS_RUNNER_SOURCE: &str = include_str!("../scripts/views-runner.ts"); +const VIEWS_JS_SDK_SOURCE: &str = include_str!("../scripts/views-sdk.ts"); +const DEFAULT_TRACE_PREVIEW_LIMIT: usize = 1000; + +#[derive(Debug, Clone, Args)] +#[command(after_help = "\ +Examples: + bt views push ./views + bt views push ./conversation.view.tsx --if-exists replace + bt views trace bootstrap + bt views dataset bootstrap --dataset test-dataset + bt views trace preview ./conversation.view.tsx --url + bt views trace preview ./conversation.view.tsx --trace-id + bt views dataset preview ./dataset.view.tsx --dataset test-dataset --row-index 0 +")] +pub struct ViewsArgs { + #[command(subcommand)] + command: ViewsCommands, +} + +#[derive(Debug, Clone, Subcommand)] +enum ViewsCommands { + /// Push local custom view definitions + Push(ViewsPushArgs), + /// Work with trace custom views + #[command(visible_alias = "traces")] + Trace(TraceViewsArgs), + /// Work with dataset custom views + #[command(visible_alias = "datasets")] + Dataset(DatasetViewsArgs), + /// Preview one custom view locally + #[command(hide = true)] + Preview(ViewsPreviewArgs), +} + +#[derive(Debug, Clone, Args)] +struct ViewsPushArgs { + /// File or directory path(s) to scan for custom view definitions. + #[arg(value_name = "PATH")] + paths: Vec, + + /// File or directory path(s) to scan for custom view definitions. + #[arg( + long = "file", + env = "BT_VIEWS_PUSH_FILES", + value_name = "PATH", + value_delimiter = ',' + )] + file_flag: Vec, + + /// Behavior when a custom view with the same slug already exists. + #[arg( + long = "if-exists", + env = "BT_VIEWS_PUSH_IF_EXISTS", + value_enum, + default_value = "error" + )] + if_exists: IfExistsMode, + + /// Override JS runner binary (e.g. tsx, vite-node, deno). + #[arg(long, env = "BT_VIEWS_PUSH_RUNNER", value_name = "RUNNER")] + runner: Option, + + /// Optional tsconfig path for JS runner and browser bundling. + #[arg(long, env = "BT_VIEWS_PUSH_TSCONFIG", value_name = "PATH")] + tsconfig: Option, + + /// Skip confirmation prompt. + #[arg( + long, + short = 'y', + env = "BT_VIEWS_PUSH_YES", + value_parser = BoolishValueParser::new(), + default_value_t = false + )] + yes: bool, +} + +impl ViewsPushArgs { + fn resolved_paths(&self) -> Vec { + let mut paths = self.paths.clone(); + paths.extend(self.file_flag.iter().cloned()); + if paths.is_empty() { + vec![PathBuf::from(".")] + } else { + paths + } + } +} + +#[derive(Debug, Clone, Args)] +struct TraceViewsArgs { + #[command(subcommand)] + command: TraceViewsCommands, +} + +#[derive(Debug, Clone, Subcommand)] +enum TraceViewsCommands { + /// Create a starter trace custom view file + Bootstrap(TraceViewBootstrapArgs), + /// Preview one trace custom view locally + Preview(TraceViewPreviewArgs), +} + +#[derive(Debug, Clone, Args)] +struct DatasetViewsArgs { + #[command(subcommand)] + command: DatasetViewsCommands, +} + +#[derive(Debug, Clone, Subcommand)] +enum DatasetViewsCommands { + /// Create a starter dataset custom view file + Bootstrap(DatasetViewBootstrapArgs), + /// Preview one dataset custom view locally + Preview(DatasetViewPreviewArgs), +} + +#[derive(Debug, Clone, Args)] +struct BootstrapCommonArgs { + /// Output file path. Defaults to views/.view.tsx. + #[arg(value_name = "PATH", conflicts_with = "file_flag")] + path: Option, + + /// Output file path. Defaults to views/.view.tsx. + #[arg(long = "file", env = "BT_VIEWS_BOOTSTRAP_FILE", value_name = "PATH")] + file_flag: Option, + + /// Overwrite an existing file. + #[arg( + long, + short = 'f', + env = "BT_VIEWS_BOOTSTRAP_FORCE", + value_parser = BoolishValueParser::new(), + default_value_t = false + )] + force: bool, +} + +#[derive(Debug, Clone, Args)] +struct TraceViewBootstrapArgs { + #[command(flatten)] + common: BootstrapCommonArgs, +} + +#[derive(Debug, Clone, Args)] +struct DatasetViewBootstrapArgs { + #[command(flatten)] + common: BootstrapCommonArgs, + + /// Dataset name to reference in the starter view. + #[arg( + long, + env = "BT_VIEWS_BOOTSTRAP_DATASET", + value_name = "NAME", + conflicts_with = "dataset_id" + )] + dataset: Option, + + /// Dataset id to reference in the starter view. + #[arg( + long = "dataset-id", + env = "BT_VIEWS_BOOTSTRAP_DATASET_ID", + value_name = "ID", + conflicts_with = "dataset" + )] + dataset_id: Option, +} + +#[derive(Debug, Serialize)] +struct BootstrapResult { + path: String, + view_type: ViewType, +} + +#[derive(Debug, Clone, Args)] +struct PreviewCommonArgs { + /// Custom view file to preview. + #[arg(value_name = "PATH")] + path: PathBuf, + + /// View slug or name when the file registers multiple views. + #[arg(long, env = "BT_VIEWS_PREVIEW_VIEW")] + view: Option, + + /// Local port to bind. Defaults to an ephemeral port. + #[arg(long, env = "BT_VIEWS_PREVIEW_PORT", default_value_t = 0)] + port: u16, + + /// Do not open a browser. + #[arg( + long, + env = "BT_VIEWS_PREVIEW_NO_OPEN", + value_parser = BoolishValueParser::new(), + default_value_t = false + )] + no_open: bool, + + /// Override JS runner binary (e.g. tsx, vite-node, deno). + #[arg(long, env = "BT_VIEWS_PREVIEW_RUNNER", value_name = "RUNNER")] + runner: Option, + + /// Optional tsconfig path for JS runner and browser bundling. + #[arg(long, env = "BT_VIEWS_PREVIEW_TSCONFIG", value_name = "PATH")] + tsconfig: Option, +} + +#[derive(Debug, Clone, Args)] +struct TraceViewPreviewArgs { + #[command(flatten)] + common: PreviewCommonArgs, + + #[command(flatten)] + target: TracePreviewTargetArgs, + + /// Print BTQL queries used for preview data. + #[arg( + long, + env = "BT_VIEWS_PREVIEW_PRINT_QUERIES", + value_parser = BoolishValueParser::new(), + default_value_t = false + )] + print_queries: bool, +} + +#[derive(Debug, Clone, Args)] +struct TracePreviewTargetArgs { + /// Braintrust app URL to resolve trace preview data from. + #[arg(long, env = "BT_VIEWS_PREVIEW_URL")] + url: Option, + + /// Object reference, format: project_logs:. + #[arg(long, env = "BT_VIEWS_PREVIEW_OBJECT_REF")] + object_ref: Option, + + /// Project ID to query for trace preview data. + #[arg(long, env = "BT_VIEWS_PREVIEW_PROJECT_ID")] + project_id: Option, + + /// Root span id for trace preview data. + #[arg( + long = "trace-id", + alias = "root-span-id", + env = "BT_VIEWS_PREVIEW_TRACE_ID" + )] + trace_id: Option, + + /// Selected span id or row id for trace preview data. + #[arg(long, env = "BT_VIEWS_PREVIEW_SPAN_ID")] + span_id: Option, +} + +#[derive(Debug, Clone, Args)] +struct DatasetViewPreviewArgs { + #[command(flatten)] + common: PreviewCommonArgs, + + #[command(flatten)] + target: DatasetPreviewTargetArgs, +} + +#[derive(Debug, Clone, Args)] +struct DatasetPreviewTargetArgs { + /// Dataset name or id for dataset preview data. + #[arg(long, env = "BT_VIEWS_PREVIEW_DATASET")] + dataset: Option, + + /// Dataset row id for dataset preview data. + #[arg(long, env = "BT_VIEWS_PREVIEW_ROW_ID")] + row_id: Option, + + /// Dataset row index for dataset preview data. + #[arg(long, env = "BT_VIEWS_PREVIEW_ROW_INDEX")] + row_index: Option, +} + +#[derive(Debug, Clone, Args)] +struct ViewsPreviewArgs { + #[command(flatten)] + common: PreviewCommonArgs, + + #[command(flatten)] + trace: TracePreviewTargetArgs, + + #[command(flatten)] + dataset: DatasetPreviewTargetArgs, + + /// Print BTQL queries used for preview data. + #[arg( + long, + env = "BT_VIEWS_PREVIEW_PRINT_QUERIES", + value_parser = BoolishValueParser::new(), + default_value_t = false + )] + print_queries: bool, +} + +#[derive(Debug, Deserialize)] +struct ViewsManifest { + runtime_context: ViewsRuntimeContext, + #[serde(default)] + files: Vec, +} + +#[derive(Debug, Deserialize)] +struct ViewsRuntimeContext { + runtime: String, + version: String, +} + +#[derive(Debug, Deserialize)] +struct ViewsManifestFile { + source_file: String, + #[serde(default)] + entries: Vec, +} + +#[derive(Debug, Deserialize, Clone)] +struct ViewManifestEntry { + view_type: ViewType, + name: String, + slug: String, + code: String, + #[serde(default)] + description: Option, + #[serde(default)] + project_id: Option, + #[serde(default)] + project_name: Option, + #[serde(default)] + dataset_id: Option, + #[serde(default)] + dataset_name: Option, + #[serde(default)] + metadata: Option, + #[serde(default)] + tags: Option>, +} + +#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "snake_case")] +enum ViewType { + Trace, + Dataset, +} + +impl ViewType { + fn label(self) -> &'static str { + match self { + Self::Trace => "trace", + Self::Dataset => "dataset", + } + } +} + +#[derive(Debug, Clone)] +struct PreparedView { + source_file: String, + entry: ViewManifestEntry, + project: Project, + dataset: Option, +} + +#[derive(Debug, Serialize)] +struct PushedView { + source_file: String, + name: String, + slug: String, + view_type: ViewType, + project_id: String, + project_name: String, + dataset_id: Option, + function_id: Option, + url: Option, +} + +#[derive(Debug, Deserialize)] +struct BtqlResponse { + data: Vec>, +} + +#[derive(Clone)] +struct PreviewServerState { + client: ApiClient, + project_id: Option, + data: Value, +} + +struct PreviewContext { + client: ApiClient, + project: Project, + entry: ViewManifestEntry, +} + +struct TracePreviewData { + project_id: String, + data: Value, +} + +#[derive(Debug, Deserialize)] +struct SpanFieldsRequest { + #[serde(rename = "spanIds")] + span_ids: Vec, + fields: Option>, +} + +pub async fn run(base: BaseArgs, args: ViewsArgs) -> Result<()> { + match args.command { + ViewsCommands::Push(push_args) => push(base, push_args).await, + ViewsCommands::Trace(trace_args) => match trace_args.command { + TraceViewsCommands::Bootstrap(bootstrap_args) => bootstrap_trace(base, bootstrap_args), + TraceViewsCommands::Preview(preview_args) => preview_trace(base, preview_args).await, + }, + ViewsCommands::Dataset(dataset_args) => match dataset_args.command { + DatasetViewsCommands::Bootstrap(bootstrap_args) => { + bootstrap_dataset(base, bootstrap_args) + } + DatasetViewsCommands::Preview(preview_args) => { + preview_dataset(base, preview_args).await + } + }, + ViewsCommands::Preview(preview_args) => preview(base, preview_args).await, + } +} + +fn bootstrap_trace(base: BaseArgs, args: TraceViewBootstrapArgs) -> Result<()> { + let path = write_bootstrap_file(args.common, "trace.view.tsx", TRACE_VIEW_BOOTSTRAP_TEMPLATE)?; + print_bootstrap_result(base.json, ViewType::Trace, &path) +} + +fn bootstrap_dataset(base: BaseArgs, args: DatasetViewBootstrapArgs) -> Result<()> { + let dataset_ref = match ( + args.dataset_id.as_deref().map(str::trim), + args.dataset.as_deref().map(str::trim), + ) { + (Some(dataset_id), _) if !dataset_id.is_empty() => { + format!("{{ id: {dataset_id:?} }}") + } + (_, Some(dataset)) if !dataset.is_empty() => format!("{{ name: {dataset:?} }}"), + _ => "{ name: \"test-dataset\" }".to_string(), + }; + let content = dataset_view_bootstrap_template(&dataset_ref); + let path = write_bootstrap_file(args.common, "dataset.view.tsx", &content)?; + print_bootstrap_result(base.json, ViewType::Dataset, &path) +} + +fn write_bootstrap_file( + args: BootstrapCommonArgs, + default_file_name: &str, + content: &str, +) -> Result { + let selected_path = args + .path + .or(args.file_flag) + .unwrap_or_else(|| PathBuf::from("views")); + let path = if selected_path.is_dir() || selected_path.extension().is_none() { + selected_path.join(default_file_name) + } else { + selected_path + }; + let is_tsx_view_file = path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(".view.tsx")); + if !is_tsx_view_file { + bail!("custom view bootstrap path must end with .view.tsx"); + } + if path.exists() && !args.force { + bail!( + "custom view file already exists: {}. Use --force to overwrite.", + path.display() + ); + } + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create directory {}", parent.display()))?; + } + std::fs::write(&path, content) + .with_context(|| format!("failed to write custom view file {}", path.display()))?; + Ok(path) +} + +fn print_bootstrap_result(json_output: bool, view_type: ViewType, path: &Path) -> Result<()> { + let result = BootstrapResult { + path: path.display().to_string(), + view_type, + }; + if json_output { + println!("{}", serde_json::to_string_pretty(&result)?); + } else { + println!( + "Created {} custom view starter at {}", + view_type.label(), + path.display() + ); + println!( + "Preview it with: bt views {} preview {}", + view_type.label(), + path.display() + ); + } + Ok(()) +} + +const TRACE_VIEW_BOOTSTRAP_TEMPLATE: &str = r##"import React from "react"; +import { + customTraceView, + formatJson, + type TraceViewProps, +} from "braintrust/custom-views"; + +function pretty(value: unknown) { + return value === undefined ? "" : formatJson(value); +} + +function StarterTraceView({ trace, span, selectSpan }: TraceViewProps) { + const spanIds = trace.spanOrder.slice(0, 100); + + return ( +
+
+ Trace starter view + +
+ +
+
Selected span
+
row id: {span.id}
+
span id: {span.span_id}
+
children: {span.children.length}
+
+ +
+
+          {pretty(span.data.input)}
+        
+
+          {pretty(span.data.output)}
+        
+
+
+ ); +} + +customTraceView({ + name: "Starter Trace View", + slug: "starter-trace-view", + description: "Starter trace custom view generated by bt.", + component: StarterTraceView, +}); +"##; + +const DATASET_VIEW_BOOTSTRAP_TEMPLATE: &str = r##"import React from "react"; +import { + customDatasetView, + formatJson, + type DatasetViewProps, +} from "braintrust/custom-views"; + +function pretty(value: unknown) { + return value === undefined ? "" : formatJson(value); +} + +function StarterDatasetView({ id, input, expected, metadata, tags }: DatasetViewProps) { + return ( +
+
+ Dataset starter view +
row id: {id}
+ {tags.length > 0 ? ( +
tags: {tags.join(", ")}
+ ) : null} +
+ +
+
+          {pretty(input)}
+        
+
+          {pretty(expected)}
+        
+
+          {pretty(metadata)}
+        
+
+
+ ); +} + +customDatasetView({ + name: "Starter Dataset View", + slug: "starter-dataset-view", + description: "Starter dataset custom view generated by bt.", + dataset: __DATASET_REF__, + component: StarterDatasetView, +}); +"##; + +fn dataset_view_bootstrap_template(dataset_ref: &str) -> String { + DATASET_VIEW_BOOTSTRAP_TEMPLATE.replace("__DATASET_REF__", dataset_ref) +} + +async fn push(base: BaseArgs, args: ViewsPushArgs) -> Result<()> { + let auth_ctx = functions::resolve_auth_context(&base).await?; + let default_project = resolve_required_project(&base, &auth_ctx.client, true).await?; + let files = collect_view_files(&args.resolved_paths())?; + if files.is_empty() { + bail!("no custom view files found; expected files matching *.view.tsx, *.view.ts, *.view.jsx, or *.view.js"); + } + + let manifest = run_views_runner(args.runner.as_deref(), args.tsconfig.as_deref(), &files)?; + validate_manifest_runtime(&manifest)?; + let prepared = prepare_views(&auth_ctx.client, &default_project, &manifest).await?; + if prepared.is_empty() { + bail!("no custom views were registered by the selected files"); + } + + if !args.yes && ui::can_prompt() { + let prompt = format!("Push {} custom view(s)?", prepared.len()); + let confirmed = dialoguer::Confirm::new() + .with_prompt(prompt) + .default(true) + .interact()?; + if !confirmed { + bail!("custom view push cancelled"); + } + } + + let events = prepared + .iter() + .map(|view| build_insert_event(view, args.if_exists)) + .collect::>(); + + let ignored = with_spinner( + "Pushing custom views...", + functions_api::insert_functions(&auth_ctx.client, &events), + ) + .await? + .ignored_entries + .unwrap_or(0); + + let pushed = resolve_pushed_views(&auth_ctx.client, &auth_ctx.app_url, &prepared).await?; + if base.json { + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "pushed": pushed, + "ignored": ignored, + }))? + ); + return Ok(()); + } + + eprintln!( + "{} Pushed {} custom view(s)", + dialoguer::console::style("✓").green(), + prepared.len().saturating_sub(ignored) + ); + for view in pushed { + match view.url { + Some(url) => println!("{} ({}) {}", view.name, view.slug, url), + None => println!("{} ({})", view.name, view.slug), + } + } + + Ok(()) +} + +async fn preview(base: BaseArgs, args: ViewsPreviewArgs) -> Result<()> { + let context = resolve_preview_context(&base, &args.common, None).await?; + match context.entry.view_type { + ViewType::Trace => { + let trace_data = build_trace_preview_data( + &context.client, + &context.project, + &args.trace, + args.print_queries, + ) + .await?; + serve_preview( + base, + &args.common, + context.client, + Some(trace_data.project_id), + context.entry, + trace_data.data, + ) + .await + } + ViewType::Dataset => { + let preview_data = build_dataset_preview_data( + &context.client, + &context.project, + &context.entry, + &args.dataset, + ) + .await?; + serve_preview( + base, + &args.common, + context.client, + None, + context.entry, + preview_data, + ) + .await + } + } +} + +async fn preview_trace(base: BaseArgs, args: TraceViewPreviewArgs) -> Result<()> { + let context = resolve_preview_context(&base, &args.common, Some(ViewType::Trace)).await?; + let trace_data = build_trace_preview_data( + &context.client, + &context.project, + &args.target, + args.print_queries, + ) + .await?; + serve_preview( + base, + &args.common, + context.client, + Some(trace_data.project_id), + context.entry, + trace_data.data, + ) + .await +} + +async fn preview_dataset(base: BaseArgs, args: DatasetViewPreviewArgs) -> Result<()> { + let context = resolve_preview_context(&base, &args.common, Some(ViewType::Dataset)).await?; + let preview_data = build_dataset_preview_data( + &context.client, + &context.project, + &context.entry, + &args.target, + ) + .await?; + serve_preview( + base, + &args.common, + context.client, + None, + context.entry, + preview_data, + ) + .await +} + +async fn resolve_preview_context( + base: &BaseArgs, + args: &PreviewCommonArgs, + view_type: Option, +) -> Result { + let files = vec![args.path.clone()]; + let manifest = run_views_runner(args.runner.as_deref(), args.tsconfig.as_deref(), &files)?; + validate_manifest_runtime(&manifest)?; + let entry = select_preview_entry(&manifest, args.view.as_deref(), view_type)?.clone(); + + let auth_ctx = auth::login_read_only(&base).await?; + let client = ApiClient::new(&auth_ctx)?; + let default_project = resolve_required_project(&base, &client, true).await?; + let project = resolve_project_for_entry(&client, &default_project, &entry).await?; + Ok(PreviewContext { + client, + project, + entry, + }) +} + +async fn serve_preview( + base: BaseArgs, + args: &PreviewCommonArgs, + client: ApiClient, + trace_project_id: Option, + entry: ViewManifestEntry, + preview_data: Value, +) -> Result<()> { + let listener = TcpListener::bind(("127.0.0.1", args.port)) + .with_context(|| format!("failed to bind preview server on port {}", args.port))?; + let addr = listener + .local_addr() + .context("failed to read preview address")?; + let url = format!("http://{addr}"); + let state = web::Data::new(PreviewServerState { + client, + project_id: trace_project_id, + data: preview_data, + }); + let code = entry.code.clone(); + let name = entry.name.clone(); + let server = HttpServer::new(move || { + App::new() + .app_data(state.clone()) + .app_data(web::Data::new(PreviewPage { + title: name.clone(), + code: code.clone(), + })) + .route("/", web::get().to(preview_index)) + .route("/span-fields", web::post().to(preview_span_fields)) + }) + .workers(1) + .listen(listener)? + .run(); + let handle = server.handle(); + tokio::spawn(server); + + if base.json { + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "url": url, + "view": { + "name": entry.name, + "slug": entry.slug, + "type": entry.view_type, + }, + }))? + ); + } else { + println!("Previewing {} at {}", entry.name, url); + } + + if !args.no_open { + open::that(&url)?; + } + + tokio::signal::ctrl_c() + .await + .context("failed to wait for Ctrl+C")?; + handle.stop(true).await; + Ok(()) +} + +#[derive(Clone)] +struct PreviewPage { + title: String, + code: String, +} + +async fn preview_index( + state: web::Data, + page: web::Data, +) -> HttpResponse { + let html = render_preview_html(&page.title, &page.code, &state.data); + HttpResponse::Ok() + .content_type("text/html; charset=utf-8") + .body(html) +} + +async fn preview_span_fields( + state: web::Data, + body: web::Json, +) -> HttpResponse { + let Some(project_id) = state.project_id.as_deref() else { + return HttpResponse::BadRequest().json(json!({ + "error": "fetchSpanFields is only available for trace previews" + })); + }; + + let mut response = Map::new(); + let spans = state + .data + .get("trace") + .and_then(|trace| trace.get("spans")) + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + + for span_id in &body.span_ids { + let row_id = resolve_preview_row_id(&spans, span_id).unwrap_or_else(|| span_id.clone()); + match fetch_full_span_row(&state.client, project_id, &row_id, false).await { + Ok(Some(row)) => { + response.insert( + span_id.clone(), + fields_from_row(row, body.fields.as_deref()), + ); + } + Ok(None) => { + response.insert(span_id.clone(), json!({})); + } + Err(err) => { + return HttpResponse::InternalServerError().json(json!({ + "error": format!("{err:#}") + })); + } + } + } + + HttpResponse::Ok().json(Value::Object(response)) +} + +fn render_preview_html(title: &str, code: &str, data: &Value) -> String { + let data_json = script_json(data); + let code_json = script_json(&Value::String(code.to_string())); + let title_json = script_json(&Value::String(title.to_string())); + format!( + r##" + + + + + {} + + + + + + +
+ + +"##, + html_escape(title), + ) +} + +fn script_json(value: &Value) -> String { + serde_json::to_string(value) + .unwrap_or_else(|_| "null".to_string()) + .replace(" String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +fn collect_view_files(paths: &[PathBuf]) -> Result> { + let mut files = Vec::new(); + for path in paths { + let path = path.as_path(); + if !path.exists() { + bail!("custom view path not found: {}", path.display()); + } + if path.is_file() { + if is_view_file(path) { + files.push(path.to_path_buf()); + } + continue; + } + collect_view_files_in_dir(path, &mut files)?; + } + files.sort(); + files.dedup(); + Ok(files) +} + +fn collect_view_files_in_dir(dir: &Path, files: &mut Vec) -> Result<()> { + for entry in std::fs::read_dir(dir) + .with_context(|| format!("failed to read directory {}", dir.display()))? + { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + if should_skip_dir(&path) { + continue; + } + collect_view_files_in_dir(&path, files)?; + } else if path.is_file() && is_view_file(&path) { + files.push(path); + } + } + Ok(()) +} + +fn should_skip_dir(path: &Path) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| { + matches!( + name, + ".git" | ".bt" | "node_modules" | "target" | "dist" | "build" | ".venv" | "venv" + ) + }) +} + +fn is_view_file(path: &Path) -> bool { + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + return false; + }; + [".view.tsx", ".view.ts", ".view.jsx", ".view.js"] + .iter() + .any(|suffix| name.ends_with(suffix)) +} + +fn run_views_runner( + runner: Option<&str>, + tsconfig: Option<&Path>, + files: &[PathBuf], +) -> Result { + let sdk_script = js_runner::materialize_runner_script_in_cwd( + "views-runners", + VIEWS_JS_SDK_FILE, + VIEWS_JS_SDK_SOURCE, + ) + .context("failed to materialize custom views SDK helper")?; + let runner_script = js_runner::materialize_runner_script_in_cwd( + "views-runners", + VIEWS_JS_RUNNER_FILE, + VIEWS_JS_RUNNER_SOURCE, + ) + .context("failed to materialize custom views runner")?; + let mut command = js_runner::build_js_runner_command(runner, &runner_script, files); + command.env("BT_VIEWS_SDK_PATH", &sdk_script); + if let Some(tsconfig) = tsconfig { + command.env("TS_NODE_PROJECT", tsconfig); + command.env("TSX_TSCONFIG_PATH", tsconfig); + } + + let output = command.output().with_context(|| { + format!( + "failed to spawn custom views runner: {}", + command_display(&command) + ) + })?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let details = stderr.trim(); + let details = if details.is_empty() { + stdout.trim() + } else { + details + }; + bail!( + "custom views runner exited with status {}: {}", + output.status, + details + ); + } + + let stdout = + String::from_utf8(output.stdout).context("custom views runner output was not UTF-8")?; + serde_json::from_str(&stdout).with_context(|| { + format!( + "failed to parse custom views runner output as JSON: {}", + stdout.trim() + ) + }) +} + +fn command_display(command: &Command) -> String { + let mut rendered = command.get_program().to_string_lossy().to_string(); + for arg in command.get_args() { + rendered.push(' '); + rendered.push_str(&arg.to_string_lossy()); + } + rendered +} + +fn validate_manifest_runtime(manifest: &ViewsManifest) -> Result<()> { + if manifest.runtime_context.runtime != "browser" { + bail!( + "custom views runner returned unsupported runtime '{}'", + manifest.runtime_context.runtime + ); + } + if manifest.runtime_context.version.trim().is_empty() { + bail!("custom views runner returned an empty runtime version"); + } + Ok(()) +} + +async fn prepare_views( + client: &ApiClient, + default_project: &Project, + manifest: &ViewsManifest, +) -> Result> { + let mut prepared = Vec::new(); + let mut seen = BTreeSet::new(); + for file in &manifest.files { + for entry in &file.entries { + let key = ( + file.source_file.clone(), + entry.view_type, + entry.slug.clone(), + ); + if !seen.insert(key) { + bail!( + "duplicate custom view slug '{}' in {}", + entry.slug, + file.source_file + ); + } + let project = resolve_project_for_entry(client, default_project, entry).await?; + let dataset = if entry.view_type == ViewType::Dataset { + Some(resolve_dataset_for_entry(client, &project, entry).await?) + } else { + None + }; + prepared.push(PreparedView { + source_file: file.source_file.clone(), + entry: entry.clone(), + project, + dataset, + }); + } + } + Ok(prepared) +} + +async fn resolve_project_for_entry( + client: &ApiClient, + default_project: &Project, + entry: &ViewManifestEntry, +) -> Result { + if let Some(project_id) = entry + .project_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if project_id == default_project.id { + return Ok(default_project.clone()); + } + let projects = list_projects(client).await?; + return projects + .into_iter() + .find(|project| project.id == project_id) + .ok_or_else(|| anyhow!("project id '{project_id}' not found")); + } + + if let Some(project_name) = entry + .project_name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if project_name == default_project.name { + return Ok(default_project.clone()); + } + return get_project_by_name(client, project_name) + .await? + .ok_or_else(|| anyhow!("project '{project_name}' not found")); + } + + Ok(default_project.clone()) +} + +async fn resolve_dataset_for_entry( + client: &ApiClient, + project: &Project, + entry: &ViewManifestEntry, +) -> Result { + if let Some(dataset_id) = entry + .dataset_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + let datasets = datasets_api::list_datasets(client, &project.id).await?; + return datasets + .into_iter() + .find(|dataset| dataset.id == dataset_id) + .ok_or_else(|| { + anyhow!( + "dataset id '{dataset_id}' not found in project '{}'", + project.name + ) + }); + } + + let dataset_name = entry + .dataset_name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + anyhow!( + "dataset custom view '{}' requires dataset id or name", + entry.slug + ) + })?; + + datasets_api::get_dataset_by_name(client, &project.id, dataset_name) + .await? + .ok_or_else(|| { + anyhow!( + "dataset '{dataset_name}' not found in project '{}'", + project.name + ) + }) +} + +fn build_insert_event(view: &PreparedView, if_exists: IfExistsMode) -> Value { + let mut object = Map::new(); + object.insert( + "project_id".to_string(), + Value::String(view.project.id.clone()), + ); + object.insert("name".to_string(), Value::String(view.entry.name.clone())); + object.insert("slug".to_string(), Value::String(view.entry.slug.clone())); + object.insert( + "description".to_string(), + Value::String(view.entry.description.clone().unwrap_or_default()), + ); + object.insert( + "function_type".to_string(), + Value::String("custom_view".to_string()), + ); + object.insert( + "if_exists".to_string(), + Value::String(if_exists.as_str().to_string()), + ); + object.insert( + "function_data".to_string(), + json!({ + "type": "code", + "data": { + "type": "inline", + "runtime_context": { + "runtime": "browser", + "version": "latest", + }, + "code": view.entry.code, + } + }), + ); + + if let Some(metadata) = &view.entry.metadata { + object.insert("metadata".to_string(), metadata.clone()); + } + if let Some(tags) = &view.entry.tags { + object.insert( + "tags".to_string(), + Value::Array(tags.iter().cloned().map(Value::String).collect()), + ); + } + if let Some(dataset) = &view.dataset { + object.insert( + "origin".to_string(), + json!({ + "object_type": "dataset", + "object_id": dataset.id, + }), + ); + } + + Value::Object(object) +} + +async fn resolve_pushed_views( + client: &ApiClient, + app_url: &str, + views: &[PreparedView], +) -> Result> { + let mut pushed = Vec::new(); + for view in views { + let function = + functions_api::get_function_by_slug(client, &view.project.id, &view.entry.slug, None) + .await + .ok() + .flatten(); + let function_id = function.as_ref().map(|function| function.id.clone()); + let url = function_id + .as_deref() + .map(|id| custom_view_url(app_url, client.org_name(), view, id)); + pushed.push(PushedView { + source_file: view.source_file.clone(), + name: view.entry.name.clone(), + slug: view.entry.slug.clone(), + view_type: view.entry.view_type, + project_id: view.project.id.clone(), + project_name: view.project.name.clone(), + dataset_id: view.dataset.as_ref().map(|dataset| dataset.id.clone()), + function_id, + url, + }); + } + Ok(pushed) +} + +fn custom_view_url( + app_url: &str, + org_name: &str, + view: &PreparedView, + function_id: &str, +) -> String { + match (&view.entry.view_type, view.dataset.as_ref()) { + (ViewType::Dataset, Some(dataset)) => { + let mut url = app_project_url( + app_url, + org_name, + &view.project.name, + &["datasets", &dataset.name], + ); + write!(url, "?dvt=custom&dv={}", encode(function_id)).ok(); + url + } + _ => app_project_url_with_encoded_path( + app_url, + org_name, + &view.project.name, + &format!("logs?tvt=custom&tv={}", encode(function_id)), + ), + } +} + +fn select_preview_entry<'a>( + manifest: &'a ViewsManifest, + selector: Option<&str>, + view_type: Option, +) -> Result<&'a ViewManifestEntry> { + let entries = manifest + .files + .iter() + .flat_map(|file| file.entries.iter()) + .filter(|entry| view_type.is_none_or(|view_type| entry.view_type == view_type)) + .collect::>(); + if entries.is_empty() { + match view_type { + Some(view_type) => bail!( + "selected file did not register any {} custom views", + view_type.label() + ), + None => bail!("selected file did not register any custom views"), + } + } + if let Some(selector) = selector { + let matches = entries + .into_iter() + .filter(|entry| entry.slug == selector || entry.name == selector) + .collect::>(); + return match matches.as_slice() { + [entry] => Ok(*entry), + [] => match view_type { + Some(view_type) => bail!( + "{} custom view '{selector}' not found in selected file", + view_type.label() + ), + None => bail!("custom view '{selector}' not found in selected file"), + }, + _ => match view_type { + Some(view_type) => bail!( + "{} custom view selector '{selector}' matched multiple views", + view_type.label() + ), + None => bail!("custom view selector '{selector}' matched multiple views"), + }, + }; + } + if entries.len() == 1 { + return Ok(entries[0]); + } + match view_type { + Some(view_type) => bail!( + "selected file registers multiple {} custom views; pass --view ", + view_type.label() + ), + None => bail!("selected file registers multiple custom views; pass --view "), + } +} + +async fn build_dataset_preview_data( + client: &ApiClient, + project: &Project, + entry: &ViewManifestEntry, + args: &DatasetPreviewTargetArgs, +) -> Result { + let dataset = match args.dataset.as_deref() { + Some(selector) => resolve_dataset_by_selector(client, project, selector).await?, + None => resolve_dataset_for_entry(client, project, entry).await?, + }; + + let row = if let Some(row_id) = args.row_id.as_deref() { + datasets_api::get_dataset_row_by_id(client, &dataset.id, row_id) + .await? + .ok_or_else(|| anyhow!("dataset row id '{row_id}' not found in '{}'", dataset.name))? + } else { + let index = args.row_index.unwrap_or(0); + let limit = index + 1; + let (rows, _) = datasets_api::list_dataset_rows_limited( + client, + &dataset.id, + Some(limit), + datasets_api::DatasetRowsPreviewLength::Full, + ) + .await?; + rows.into_iter().nth(index).ok_or_else(|| { + anyhow!( + "dataset '{}' does not have row index {}", + dataset.name, + index + ) + })? + }; + + Ok(json!({ + "props": { + "id": row.get("id").cloned().unwrap_or(Value::Null), + "input": row.get("input").cloned().unwrap_or(Value::Null), + "expected": row.get("expected").cloned().unwrap_or(Value::Null), + "metadata": row.get("metadata").cloned().unwrap_or_else(|| json!({})), + "tags": row.get("tags").cloned().unwrap_or_else(|| json!([])), + }, + "dataset": dataset, + })) +} + +async fn resolve_dataset_by_selector( + client: &ApiClient, + project: &Project, + selector: &str, +) -> Result { + let datasets = datasets_api::list_datasets(client, &project.id).await?; + datasets + .into_iter() + .find(|dataset| dataset.id == selector || dataset.name == selector) + .ok_or_else(|| { + anyhow!( + "dataset '{selector}' not found in project '{}'", + project.name + ) + }) +} + +async fn build_trace_preview_data( + client: &ApiClient, + project: &Project, + args: &TracePreviewTargetArgs, + print_queries: bool, +) -> Result { + let target = resolve_trace_preview_target(client, project, args).await?; + let rows = fetch_trace_rows( + client, + &target.project_id, + &target.root_span_id, + DEFAULT_TRACE_PREVIEW_LIMIT, + print_queries, + ) + .await?; + if rows.is_empty() { + bail!("trace '{}' returned no spans", target.root_span_id); + } + let (trace, selected_span) = + build_trace_payload(rows, &target.root_span_id, target.span_id.as_deref())?; + Ok(TracePreviewData { + project_id: target.project_id, + data: json!({ + "trace": trace, + "span": selected_span, + }), + }) +} + +#[derive(Debug)] +struct TracePreviewTarget { + project_id: String, + root_span_id: String, + span_id: Option, +} + +async fn resolve_trace_preview_target( + client: &ApiClient, + default_project: &Project, + args: &TracePreviewTargetArgs, +) -> Result { + if let Some(url) = args.url.as_deref() { + let parsed = parse_trace_url(url)?; + let project_id = match parsed.project.as_deref() { + Some(project) if project == default_project.id || project == default_project.name => { + default_project.id.clone() + } + Some(project) if is_uuid_like(project) => project.to_string(), + Some(project) => { + get_project_by_name(client, project) + .await? + .ok_or_else(|| anyhow!("project '{project}' from trace URL not found"))? + .id + } + None => default_project.id.clone(), + }; + let root_span_id = parsed + .row_ref + .or(parsed.span_id.clone()) + .ok_or_else(|| anyhow!("trace URL must include query parameter r or s"))?; + return Ok(TracePreviewTarget { + project_id, + root_span_id, + span_id: args.span_id.clone().or(parsed.span_id), + }); + } + + let project_id = if let Some(object_ref) = args.object_ref.as_deref() { + let (object_type, object_name) = object_ref.split_once(':').ok_or_else(|| { + anyhow!("invalid --object-ref '{object_ref}', expected project_logs:") + })?; + if object_type != "project_logs" { + bail!("bt views preview currently supports project_logs object refs"); + } + object_name.to_string() + } else { + args.project_id + .clone() + .unwrap_or_else(|| default_project.id.clone()) + }; + let root_span_id = args + .trace_id + .clone() + .ok_or_else(|| anyhow!("trace preview requires --trace-id or --url"))?; + Ok(TracePreviewTarget { + project_id, + root_span_id, + span_id: args.span_id.clone(), + }) +} + +#[derive(Debug)] +struct ParsedPreviewTraceUrl { + project: Option, + row_ref: Option, + span_id: Option, +} + +fn parse_trace_url(input: &str) -> Result { + let parsed_url = Url::parse(input) + .or_else(|_| Url::parse(&format!("https://{}", input.trim_start_matches('/')))) + .context("invalid trace URL")?; + let mut parsed = ParsedPreviewTraceUrl { + project: None, + row_ref: None, + span_id: None, + }; + if let Some(segments) = parsed_url.path_segments() { + let parts = segments.filter(|part| !part.is_empty()).collect::>(); + if parts.len() >= 4 && parts[0] == "app" && parts[2] == "p" { + parsed.project = Some(parts[3].to_string()); + } + } + for (key, value) in parsed_url.query_pairs() { + match key.as_ref() { + "r" if !value.is_empty() => parsed.row_ref = Some(value.to_string()), + "s" if !value.is_empty() => parsed.span_id = Some(value.to_string()), + _ => {} + } + } + Ok(parsed) +} + +fn is_uuid_like(value: &str) -> bool { + let bytes = value.as_bytes(); + if bytes.len() != 36 { + return false; + } + for (idx, b) in bytes.iter().enumerate() { + let is_hyphen = matches!(idx, 8 | 13 | 18 | 23); + if is_hyphen { + if *b != b'-' { + return false; + } + } else if !(*b as char).is_ascii_hexdigit() { + return false; + } + } + true +} + +async fn fetch_trace_rows( + client: &ApiClient, + project_id: &str, + root_span_id: &str, + limit: usize, + print_queries: bool, +) -> Result>> { + let query = format!( + "select: * | from: project_logs({}) spans | filter: root_span_id = {} | preview_length: 125 | sort: _pagination_key ASC | limit: {}", + sql_quote(project_id), + sql_quote(root_span_id), + limit, + ); + maybe_print_query(print_queries, "custom-view-preview-trace", &query); + execute_query(client, &query) + .await + .with_context(|| format!("BTQL query failed: {query}")) + .map(|response| response.data) +} + +async fn fetch_full_span_row( + client: &ApiClient, + project_id: &str, + row_id: &str, + print_queries: bool, +) -> Result>> { + let query = format!( + "select: * | from: project_logs({}) spans | filter: id = {} | preview_length: -1 | limit: 1", + sql_quote(project_id), + sql_quote(row_id), + ); + maybe_print_query(print_queries, "custom-view-preview-span-fields", &query); + execute_query(client, &query) + .await + .with_context(|| format!("BTQL query failed: {query}")) + .map(|response| response.data.into_iter().next()) +} + +async fn execute_query(client: &ApiClient, query: &str) -> Result { + let body = json!({ + "query": query, + "fmt": "json", + "query_source": "bt_views_preview_7e8680d0f2484e2fbef8a61f2de0b9df", + }); + let org_name = client.org_name(); + let headers = if org_name.is_empty() { + Vec::new() + } else { + vec![("x-bt-org-name", org_name)] + }; + client.post_with_headers("/btql", &body, &headers).await +} + +fn maybe_print_query(enabled: bool, label: &str, query: &str) { + if enabled { + eprintln!("bt views [{label}] BTQL:\n{query}\n"); + } +} + +fn sql_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "''")) +} + +fn build_trace_payload( + rows: Vec>, + root_span_id: &str, + selected_selector: Option<&str>, +) -> Result<(Value, Value)> { + let mut spans = Map::new(); + let mut span_order = Vec::new(); + let mut parent_children: BTreeMap> = BTreeMap::new(); + let mut row_id_to_span_id = BTreeMap::new(); + + for row in &rows { + let Some(span_id) = row.get("span_id").and_then(Value::as_str) else { + continue; + }; + span_order.push(Value::String(span_id.to_string())); + if let Some(row_id) = row.get("id").and_then(Value::as_str) { + row_id_to_span_id.insert(row_id.to_string(), span_id.to_string()); + } + if let Some(parent) = parent_span_id(row) { + parent_children + .entry(parent) + .or_default() + .push(span_id.to_string()); + } + } + + for row in rows { + let Some(span_id) = row.get("span_id").and_then(Value::as_str) else { + continue; + }; + let span_id = span_id.to_string(); + let children = parent_children + .get(&span_id) + .cloned() + .unwrap_or_default() + .into_iter() + .map(Value::String) + .collect::>(); + spans.insert(span_id.clone(), row_to_custom_span(row, children)); + } + + let selected_span_id = selected_selector + .and_then(|selector| { + spans + .contains_key(selector) + .then(|| selector.to_string()) + .or_else(|| row_id_to_span_id.get(selector).cloned()) + }) + .or_else(|| { + spans + .contains_key(root_span_id) + .then(|| root_span_id.to_string()) + }) + .or_else(|| { + span_order + .first() + .and_then(Value::as_str) + .map(ToOwned::to_owned) + }) + .ok_or_else(|| anyhow!("trace has no selectable spans"))?; + let selected_span = spans + .get(&selected_span_id) + .cloned() + .ok_or_else(|| anyhow!("selected span '{selected_span_id}' not found"))?; + + Ok(( + json!({ + "rootSpanId": root_span_id, + "selectedSpanId": selected_span_id, + "spanOrder": span_order, + "spans": spans, + }), + selected_span, + )) +} + +fn row_to_custom_span(row: Map, children: Vec) -> Value { + let data_fields = [ + "input", + "output", + "expected", + "metadata", + "scores", + "metrics", + "error", + "tags", + "span_attributes", + ]; + let mut data = Map::new(); + for field in data_fields { + if let Some(value) = row.get(field) { + data.insert(field.to_string(), value.clone()); + } + } + + let mut span = Map::new(); + for field in ["id", "span_id", "root_span_id", "parent_span_id"] { + if let Some(value) = row.get(field) { + span.insert(field.to_string(), value.clone()); + } + } + if let Some(value) = row.get("span_parents") { + span.insert("span_parents".to_string(), value.clone()); + } + span.insert("data".to_string(), Value::Object(data)); + span.insert("children".to_string(), Value::Array(children)); + Value::Object(span) +} + +fn parent_span_id(row: &Map) -> Option { + row.get("parent_span_id") + .and_then(Value::as_str) + .map(ToOwned::to_owned) + .or_else(|| { + row.get("span_parents") + .and_then(Value::as_array) + .and_then(|parents| parents.last()) + .and_then(Value::as_str) + .map(ToOwned::to_owned) + }) +} + +fn resolve_preview_row_id(spans: &Map, requested: &str) -> Option { + spans + .get(requested) + .and_then(|span| span.get("id")) + .and_then(Value::as_str) + .map(ToOwned::to_owned) + .or_else(|| { + spans.values().find_map(|span| { + let row_id = span.get("id").and_then(Value::as_str)?; + (row_id == requested).then(|| row_id.to_string()) + }) + }) +} + +fn fields_from_row(row: Map, fields: Option<&[String]>) -> Value { + let requested = fields + .map(|fields| fields.iter().map(String::as_str).collect::>()) + .unwrap_or_else(|| vec!["input", "output", "expected", "metadata"]); + let mut object = Map::new(); + for field in requested { + if matches!(field, "input" | "output" | "expected" | "metadata") { + if let Some(value) = row.get(field) { + object.insert(field.to_string(), value.clone()); + } + } + } + Value::Object(object) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_view_files() { + assert!(is_view_file(Path::new("conversation.view.tsx"))); + assert!(is_view_file(Path::new("dataset.view.js"))); + assert!(!is_view_file(Path::new("regular.tsx"))); + } + + #[test] + fn builds_inline_custom_view_insert_event() { + let view = PreparedView { + source_file: "test.view.tsx".to_string(), + entry: ViewManifestEntry { + view_type: ViewType::Trace, + name: "Test View".to_string(), + slug: "test-view".to_string(), + code: "module.exports = function View() {}".to_string(), + description: Some("desc".to_string()), + project_id: None, + project_name: None, + dataset_id: None, + dataset_name: None, + metadata: Some(json!({ "kind": "test" })), + tags: Some(vec!["review".to_string()]), + }, + project: Project { + id: "proj_test".to_string(), + name: "test-project".to_string(), + org_id: "org_test".to_string(), + description: None, + }, + dataset: None, + }; + + let event = build_insert_event(&view, IfExistsMode::Replace); + assert_eq!(event["function_type"], "custom_view"); + assert_eq!(event["if_exists"], "replace"); + assert_eq!(event["function_data"]["type"], "code"); + assert_eq!(event["function_data"]["data"]["type"], "inline"); + assert_eq!( + event["function_data"]["data"]["runtime_context"]["runtime"], + "browser" + ); + assert!(event.get("origin").is_none()); + } + + #[test] + fn dataset_insert_event_sets_origin() { + let view = PreparedView { + source_file: "dataset.view.tsx".to_string(), + entry: ViewManifestEntry { + view_type: ViewType::Dataset, + name: "Dataset View".to_string(), + slug: "dataset-view".to_string(), + code: "module.exports = function View() {}".to_string(), + description: None, + project_id: None, + project_name: None, + dataset_id: Some("dataset_test".to_string()), + dataset_name: None, + metadata: None, + tags: None, + }, + project: Project { + id: "proj_test".to_string(), + name: "test-project".to_string(), + org_id: "org_test".to_string(), + description: None, + }, + dataset: Some(datasets_api::Dataset { + id: "dataset_test".to_string(), + name: "test-dataset".to_string(), + project_id: Some("proj_test".to_string()), + description: None, + created: None, + created_at: None, + metadata: None, + }), + }; + + let event = build_insert_event(&view, IfExistsMode::Error); + assert_eq!(event["origin"]["object_type"], "dataset"); + assert_eq!(event["origin"]["object_id"], "dataset_test"); + } +} diff --git a/tests/cli.rs b/tests/cli.rs index 952a95d..6ead7d7 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -97,6 +97,113 @@ fn topics_report_help_accepts_global_org_short_conflict_free() { .stdout(predicate::str::contains("--output")); } +#[test] +fn views_help_accepts_push_trace_and_dataset_subcommands() { + bt_command() + .args(["views", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("push")) + .stdout(predicate::str::contains("trace")) + .stdout(predicate::str::contains("dataset")); +} + +#[test] +fn views_trace_help_lists_bootstrap_and_preview() { + bt_command() + .args(["views", "trace", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("bootstrap")) + .stdout(predicate::str::contains("preview")); +} + +#[test] +fn views_dataset_help_lists_bootstrap_and_preview() { + bt_command() + .args(["views", "dataset", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("bootstrap")) + .stdout(predicate::str::contains("preview")); +} + +#[test] +fn views_push_help_lists_custom_view_flags() { + bt_command() + .args(["views", "push", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--if-exists")) + .stdout(predicate::str::contains("--tsconfig")); +} + +#[test] +fn views_trace_preview_help_lists_trace_selectors() { + bt_command() + .args(["views", "trace", "preview", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--trace-id")) + .stdout(predicate::str::contains("--url")) + .stdout(predicate::str::contains("--dataset").not()) + .stdout(predicate::str::contains("--row-index").not()); +} + +#[test] +fn views_dataset_preview_help_lists_dataset_selectors() { + bt_command() + .args(["views", "dataset", "preview", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--dataset")) + .stdout(predicate::str::contains("--trace-id").not()) + .stdout(predicate::str::contains("--url").not()) + .stdout(predicate::str::contains("--row-index")); +} + +#[test] +fn views_trace_bootstrap_creates_starter_file() { + let dir = tempfile::tempdir().expect("tempdir"); + + bt_command() + .current_dir(dir.path()) + .args(["views", "trace", "bootstrap"]) + .assert() + .success() + .stdout(predicate::str::contains("views/trace.view.tsx")); + + let contents = + fs::read_to_string(dir.path().join("views/trace.view.tsx")).expect("read starter view"); + assert!(contents.contains("customTraceView")); + assert!(contents.contains("Starter Trace View")); +} + +#[test] +fn views_dataset_bootstrap_creates_starter_file_with_dataset_name() { + let dir = tempfile::tempdir().expect("tempdir"); + + bt_command() + .current_dir(dir.path()) + .args([ + "views", + "dataset", + "bootstrap", + "--dataset", + "test-dataset", + "--file", + "custom.dataset.view.tsx", + ]) + .assert() + .success() + .stdout(predicate::str::contains("custom.dataset.view.tsx")); + + let contents = + fs::read_to_string(dir.path().join("custom.dataset.view.tsx")).expect("read starter view"); + assert!(contents.contains("customDatasetView")); + assert!(contents.contains(r#"dataset: { name: "test-dataset" }"#)); +} + #[test] fn status_quiet_and_verbose_conflict() { bt_command() From b71453185589fd50e71fdb82732b7390f7c8b01c Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Thu, 2 Jul 2026 19:52:16 +0200 Subject: [PATCH 2/6] . --- scripts/views-runner.ts | 238 +++++---- scripts/views-sdk.ts | 257 +-------- src/views.rs | 1099 +++++++++++++++++++++++++++++++-------- tests/cli.rs | 205 +++++++- 4 files changed, 1232 insertions(+), 567 deletions(-) diff --git a/scripts/views-runner.ts b/scripts/views-runner.ts index 712f4bc..c9cf291 100644 --- a/scripts/views-runner.ts +++ b/scripts/views-runner.ts @@ -5,26 +5,22 @@ import { createRequire } from "node:module"; import { pathToFileURL } from "node:url"; type EsbuildModule = { - build: (options: Record) => Promise; + build: (options: Record) => Promise; }; -type JsonValue = - | string - | number - | boolean - | null - | JsonValue[] - | { [key: string]: JsonValue }; - -type RegisteredCustomView = { - kind?: string; - name?: string; - slug?: string; - description?: string; +type EsbuildBuildResult = { + metafile?: { + inputs?: Record; + }; +}; + +type CustomViewDefinition = { + kind: "trace" | "dataset"; + name: string; + slug: string; + component: unknown; project?: string | { id?: string; name?: string }; dataset?: { id?: string; name?: string }; - tags?: unknown; - metadata?: unknown; }; type ManifestEntry = { @@ -33,13 +29,10 @@ type ManifestEntry = { name: string; slug: string; code: string; - description?: string; project_id?: string; project_name?: string; dataset_id?: string; dataset_name?: string; - tags?: string[]; - metadata?: JsonValue; }; type Manifest = { @@ -49,6 +42,7 @@ type Manifest = { }; files: Array<{ source_file: string; + dependencies: string[]; entries: ManifestEntry[]; }>; }; @@ -276,29 +270,8 @@ function braintrustViewPlugin() { }; } -function validateJson(value: unknown, label: string): JsonValue | undefined { - if (value === undefined) { - return undefined; - } - try { - return JSON.parse(JSON.stringify(value)) as JsonValue; - } catch { - throw new Error(`${label} must be JSON-serializable`); - } -} - -function normalizeTags(value: unknown, slug: string): string[] | undefined { - if (value === undefined) { - return undefined; - } - if (!Array.isArray(value) || !value.every((tag) => typeof tag === "string")) { - throw new Error(`custom view '${slug}' tags must be an array of strings`); - } - return value; -} - function projectFields( - project: RegisteredCustomView["project"], + project: CustomViewDefinition["project"], ): Pick { if (project === undefined) { return {}; @@ -316,7 +289,7 @@ function projectFields( } function datasetFields( - view: RegisteredCustomView, + view: CustomViewDefinition, ): Pick { if (view.kind !== "dataset") { return {}; @@ -336,16 +309,48 @@ function datasetFields( ); } -function validateRegisteredView(view: RegisteredCustomView): void { - if (view.kind !== "trace" && view.kind !== "dataset") { - throw new Error("custom view kind must be 'trace' or 'dataset'"); +function validateCustomView( + value: unknown, + sourceFile: string, +): asserts value is CustomViewDefinition { + if (!isObject(value)) { + throw new Error( + `${sourceFile} must default-export customTraceView(...) or customDatasetView(...)`, + ); + } + if (value.kind !== "trace" && value.kind !== "dataset") { + throw new Error( + `${sourceFile} default export must be a trace or dataset custom view`, + ); + } + if (typeof value.name !== "string" || !value.name.trim()) { + throw new Error(`${sourceFile} custom view name is required`); } - if (typeof view.name !== "string" || !view.name.trim()) { - throw new Error("custom view name is required"); + if (typeof value.slug !== "string" || !value.slug.trim()) { + throw new Error(`${sourceFile} custom view '${value.name}' slug is required`); + } + if (typeof value.component !== "function") { + throw new Error( + `${sourceFile} custom view '${value.slug}' component must be a function`, + ); + } +} + +function defaultExportFromModule(loaded: unknown): unknown { + if ( + !isObject(loaded) || + !Object.prototype.hasOwnProperty.call(loaded, "default") + ) { + return undefined; } - if (typeof view.slug !== "string" || !view.slug.trim()) { - throw new Error(`custom view '${view.name}' slug is required`); + const value = loaded.default; + if ( + isObject(value) && + Object.prototype.hasOwnProperty.call(value, "default") + ) { + return value.default; } + return value; } async function bundleForDiscovery( @@ -353,8 +358,8 @@ async function bundleForDiscovery( sourceFile: string, outputFile: string, tsconfig: string | undefined, -): Promise { - await esbuild.build({ +): Promise { + return await esbuild.build({ entryPoints: [sourceFile], outfile: outputFile, bundle: true, @@ -363,46 +368,41 @@ async function bundleForDiscovery( format: "cjs", tsconfig, write: true, + metafile: true, plugins: [braintrustViewPlugin()], }); } -async function collectViews( +async function collectView( esbuild: EsbuildModule, sourceFile: string, tempDir: string, tsconfig: string | undefined, -): Promise { - (globalThis as { __braintrust_custom_views?: RegisteredCustomView[] }).__braintrust_custom_views = []; +): Promise<{ view: CustomViewDefinition; dependencies: string[] }> { installDiscoveryReactStub(); const outputFile = path.join( tempDir, `${path.basename(sourceFile).replace(/[^\w.-]/g, "_")}.collector.cjs`, ); - await bundleForDiscovery(esbuild, sourceFile, outputFile, tsconfig); - await import(`${pathToFileURL(outputFile).href}?bt_view_nonce=${Date.now()}`); - const views = - (globalThis as { __braintrust_custom_views?: RegisteredCustomView[] }) - .__braintrust_custom_views ?? []; - const seen = new Set(); - for (const view of views) { - validateRegisteredView(view); - const key = `${view.kind}:${view.slug}`; - if (seen.has(key)) { - throw new Error(`duplicate custom view slug '${view.slug}' in ${sourceFile}`); - } - seen.add(key); - } - return [...views]; + const buildResult = await bundleForDiscovery( + esbuild, + sourceFile, + outputFile, + tsconfig, + ); + const loaded = await import( + `${pathToFileURL(outputFile).href}?bt_view_nonce=${Date.now()}` + ); + const view = defaultExportFromModule(loaded); + validateCustomView(view, sourceFile); + return { view, dependencies: dependencyFiles(buildResult, tempDir) }; } -function virtualEntrySource(sourceFile: string, slug: string): string { +function virtualEntrySource(sourceFile: string): string { return ` -import ${JSON.stringify(sourceFile)}; -import { __getCustomViews } from "braintrust/custom-views"; -const view = [...__getCustomViews()].reverse().find((candidate) => candidate.slug === ${JSON.stringify(slug)}); -if (!view) { - throw new Error("Custom view not registered: ${slug.replaceAll('"', '\\"')}"); +import view from ${JSON.stringify(sourceFile)}; +if (!view || (view.kind !== "trace" && view.kind !== "dataset") || !view.component) { + throw new Error("Custom view file must default-export customTraceView(...) or customDatasetView(...)"); } export default view.component; `; @@ -411,10 +411,10 @@ export default view.component; async function bundleBrowserCode( esbuild: EsbuildModule, sourceFile: string, - view: RegisteredCustomView, + view: CustomViewDefinition, tempDir: string, tsconfig: string | undefined, -): Promise { +): Promise<{ code: string; dependencies: string[] }> { const entryFile = path.join( tempDir, `${view.slug?.replace(/[^\w.-]/g, "_") || "view"}.entry.ts`, @@ -423,9 +423,9 @@ async function bundleBrowserCode( tempDir, `${view.slug?.replace(/[^\w.-]/g, "_") || "view"}.browser.js`, ); - fs.writeFileSync(entryFile, virtualEntrySource(sourceFile, view.slug!), "utf8"); + fs.writeFileSync(entryFile, virtualEntrySource(sourceFile), "utf8"); - await esbuild.build({ + const buildResult = await esbuild.build({ entryPoints: [entryFile], outfile: outputFile, bundle: true, @@ -435,6 +435,7 @@ async function bundleBrowserCode( globalName: "__BraintrustCustomView", tsconfig, write: true, + metafile: true, treeShaking: true, jsxFactory: "React.createElement", jsxFragment: "React.Fragment", @@ -444,7 +445,39 @@ async function bundleBrowserCode( plugins: [braintrustViewPlugin()], }); - return fs.readFileSync(outputFile, "utf8"); + return { + code: fs.readFileSync(outputFile, "utf8"), + dependencies: dependencyFiles(buildResult, tempDir), + }; +} + +function dependencyFiles( + buildResult: EsbuildBuildResult, + tempDir: string, +): string[] { + const inputs = Object.keys(buildResult.metafile?.inputs ?? {}); + const dependencies = new Set(); + const tempRoot = fs.realpathSync.native(tempDir); + for (const input of inputs) { + if (input.includes(":") && !path.isAbsolute(input)) { + continue; + } + let file = path.isAbsolute(input) + ? path.normalize(input) + : path.resolve(process.cwd(), input); + if (fs.existsSync(file)) { + file = fs.realpathSync.native(file); + } + const relativeToTemp = path.relative(tempRoot, file); + if ( + relativeToTemp === "" || + (!relativeToTemp.startsWith("..") && !path.isAbsolute(relativeToTemp)) + ) { + continue; + } + dependencies.add(file); + } + return [...dependencies].sort(); } async function buildManifest(files: string[]): Promise { @@ -466,30 +499,29 @@ async function buildManifest(files: string[]): Promise { if (!fs.existsSync(sourceFile)) { throw new Error(`custom view file not found: ${sourceFile}`); } - const views = await collectViews(esbuild, sourceFile, tempDir, tsconfig); - const entries: ManifestEntry[] = []; - for (const view of views) { - const code = await bundleBrowserCode( - esbuild, - sourceFile, - view, - tempDir, - tsconfig, - ); - entries.push({ + const collected = await collectView(esbuild, sourceFile, tempDir, tsconfig); + const bundled = await bundleBrowserCode( + esbuild, + sourceFile, + collected.view, + tempDir, + tsconfig, + ); + const dependencies = [ + ...new Set([...collected.dependencies, ...bundled.dependencies]), + ].sort(); + const entries: ManifestEntry[] = [ + { kind: "view", - view_type: view.kind as "trace" | "dataset", - name: view.name!, - slug: view.slug!, - description: view.description, - code, - ...projectFields(view.project), - ...datasetFields(view), - tags: normalizeTags(view.tags, view.slug!), - metadata: validateJson(view.metadata, `custom view '${view.slug}' metadata`), - }); - } - manifest.files.push({ source_file: sourceFile, entries }); + view_type: collected.view.kind, + name: collected.view.name, + slug: collected.view.slug, + code: bundled.code, + ...projectFields(collected.view.project), + ...datasetFields(collected.view), + }, + ]; + manifest.files.push({ source_file: sourceFile, dependencies, entries }); } return manifest; diff --git a/scripts/views-sdk.ts b/scripts/views-sdk.ts index 173203c..5097c4d 100644 --- a/scripts/views-sdk.ts +++ b/scripts/views-sdk.ts @@ -1,249 +1,16 @@ -export type JsonValue = - | string - | number - | boolean - | null - | JsonValue[] - | { [key: string]: JsonValue }; +type CustomViewDefinition = Record; +type Component = (props: unknown) => unknown; -export type JsonObject = { [key: string]: JsonValue }; - -export type InlineAttachment = { - type: "inline_attachment"; - src: string; - content_type: string; - filename?: string; - data?: unknown; -}; - -export type SpanFieldName = "input" | "output" | "expected" | "metadata"; - -export type SpanFields = { - input?: unknown; - output?: unknown; - expected?: unknown; - metadata?: Record; -}; - -export type TraceViewSpan< - TInput = unknown, - TOutput = unknown, - TExpected = unknown, - TMetadata extends Record = Record, -> = { - id: string; - span_id: string; - root_span_id: string; - parent_span_id?: string | null; - span_parents?: string[]; - data: { - input?: TInput; - output?: TOutput; - expected?: TExpected; - metadata?: TMetadata; - scores?: Record; - metrics?: Record; - error?: string; - tags?: string[]; - span_attributes?: Record; - [key: string]: unknown; - }; - children: string[]; -}; - -export type TraceViewTrace = { - rootSpanId: string; - selectedSpanId: string; - spanOrder: string[]; - spans: Record; - fetchSpanFields: ( - spanIds: string | string[], - fields?: SpanFieldName[], - ) => Promise>; -}; - -export type TraceViewUpdate< - TMetadata extends Record = Record, -> = { - (field: string, value: unknown): void; - (patch: Partial<{ metadata: TMetadata; tags: string[] }>): void; -}; - -export type TraceViewProps< - TInput = unknown, - TOutput = unknown, - TExpected = unknown, - TMetadata extends Record = Record, -> = { - trace: TraceViewTrace; - span: TraceViewSpan; - update: TraceViewUpdate; - selectSpan: (spanId: string) => void; -}; - -export type DatasetViewProps< - TInput = unknown, - TExpected = unknown, - TMetadata extends Record = Record, -> = { - id: string; - input: TInput; - expected: TExpected; - metadata: TMetadata; - tags: string[]; -}; - -export type ReactComponent = (props: Props) => unknown; - -type ProjectRef = string | { id: string } | { name: string }; -type DatasetRef = { id: string } | { name: string }; - -export type CustomTraceViewDefinition< - TInput = unknown, - TOutput = unknown, - TExpected = unknown, - TMetadata extends Record = Record, -> = { - name: string; - slug: string; - component: ReactComponent< - TraceViewProps - >; - description?: string; - project?: ProjectRef; - tags?: string[]; - metadata?: JsonObject; -}; - -export type CustomDatasetViewDefinition< - TInput = unknown, - TExpected = unknown, - TMetadata extends Record = Record, -> = { - name: string; - slug: string; - dataset: DatasetRef; - component: ReactComponent>; - description?: string; - project?: ProjectRef; - tags?: string[]; - metadata?: JsonObject; -}; - -export type RegisteredCustomView = { - kind: "trace" | "dataset"; - name: string; - slug: string; - component: ReactComponent; - description?: string; - project?: ProjectRef; - dataset?: DatasetRef; - tags?: string[]; - metadata?: JsonObject; -}; - -declare global { - // eslint-disable-next-line no-var - var __braintrust_custom_views: RegisteredCustomView[] | undefined; -} - -function registry(): RegisteredCustomView[] { - const current = globalThis.__braintrust_custom_views; - if (Array.isArray(current)) { - return current; - } - globalThis.__braintrust_custom_views = []; - return globalThis.__braintrust_custom_views; -} - -function registerView(view: RegisteredCustomView): void { - const views = registry(); - const existing = views.findIndex( - (candidate) => - candidate.kind === view.kind && candidate.slug === view.slug, - ); - if (existing >= 0) { - views.splice(existing, 1); - } - views.push(view); -} - -export function __getCustomViews(): RegisteredCustomView[] { - return [...registry()]; -} - -export function __clearCustomViews(): void { - registry().splice(0); -} - -export function customTraceView< - TInput = unknown, - TOutput = unknown, - TExpected = unknown, - TMetadata extends Record = Record, ->( - definition: CustomTraceViewDefinition< - TInput, - TOutput, - TExpected, - TMetadata - >, -): ReactComponent> { - registerView({ - kind: "trace", - name: definition.name, - slug: definition.slug, - component: definition.component, - description: definition.description, - project: definition.project, - tags: definition.tags, - metadata: definition.metadata, - }); - return definition.component; -} - -export function customDatasetView< - TInput = unknown, - TExpected = unknown, - TMetadata extends Record = Record, ->( - definition: CustomDatasetViewDefinition, -): ReactComponent> { - registerView({ - kind: "dataset", - name: definition.name, - slug: definition.slug, - component: definition.component, - description: definition.description, - project: definition.project, - dataset: definition.dataset, - tags: definition.tags, - metadata: definition.metadata, - }); - return definition.component; -} - -export function isInlineAttachment(value: unknown): value is InlineAttachment { - return ( - typeof value === "object" && - value !== null && - (value as { type?: unknown }).type === "inline_attachment" && - typeof (value as { src?: unknown }).src === "string" && - typeof (value as { content_type?: unknown }).content_type === "string" - ); -} - -export function parseMaybeJson(value: unknown): unknown { - if (typeof value !== "string") { - return value; - } - try { - return JSON.parse(value); - } catch { - return value; - } +export function customTraceView( + definition: CustomViewDefinition, + component: Component, +) { + return { ...definition, component, kind: "trace" as const }; } -export function formatJson(value: unknown): string { - return JSON.stringify(value, null, 2); +export function customDatasetView( + definition: CustomViewDefinition, + component: Component, +) { + return { ...definition, component, kind: "dataset" as const }; } diff --git a/src/views.rs b/src/views.rs index 9390a2a..f67a261 100644 --- a/src/views.rs +++ b/src/views.rs @@ -3,6 +3,8 @@ use std::fmt::Write as _; use std::net::TcpListener; use std::path::{Path, PathBuf}; use std::process::Command; +use std::sync::Mutex; +use std::time::UNIX_EPOCH; use actix_web::{web, App, HttpResponse, HttpServer}; use anyhow::{anyhow, bail, Context, Result}; @@ -18,7 +20,7 @@ use crate::datasets::api as datasets_api; use crate::functions::{self, api as functions_api, IfExistsMode}; use crate::http::ApiClient; use crate::js_runner; -use crate::project_context::resolve_required_project; +use crate::project_context::{resolve_project_optional, resolve_required_project}; use crate::projects::api::{get_project_by_name, list_projects, Project}; use crate::ui::{self, with_spinner}; use crate::utils::{app_project_url, app_project_url_with_encoded_path}; @@ -28,17 +30,22 @@ const VIEWS_JS_SDK_FILE: &str = "views-sdk.ts"; const VIEWS_JS_RUNNER_SOURCE: &str = include_str!("../scripts/views-runner.ts"); const VIEWS_JS_SDK_SOURCE: &str = include_str!("../scripts/views-sdk.ts"); const DEFAULT_TRACE_PREVIEW_LIMIT: usize = 1000; +const DEFAULT_CUSTOM_VIEWS_DIR: &str = "braintrust-custom-views"; +const CUSTOM_VIEW_TSCONFIG_FILE: &str = "tsconfig.json"; +const CUSTOM_VIEW_TYPES_FILE: &str = "custom-view-env.d.ts"; +const VIEW_FILE_PATTERN_HELP: &str = + "*.view.tsx, *.view.ts, *.view.jsx, *.view.js, *-view.tsx, *-view.ts, *-view.jsx, or *-view.js"; #[derive(Debug, Clone, Args)] #[command(after_help = "\ Examples: bt views push ./views bt views push ./conversation.view.tsx --if-exists replace - bt views trace bootstrap - bt views dataset bootstrap --dataset test-dataset - bt views trace preview ./conversation.view.tsx --url - bt views trace preview ./conversation.view.tsx --trace-id - bt views dataset preview ./dataset.view.tsx --dataset test-dataset --row-index 0 + bt views trace bootstrap 'Trace Review' + bt views dataset bootstrap 'Dataset Review' --dataset test-dataset + bt views trace preview ./conversation.trace-view.tsx --url + bt views trace preview ./conversation.trace-view.tsx --trace-id + bt views dataset preview ./dataset.dataset-view.tsx --dataset test-dataset --row-index 0 ")] pub struct ViewsArgs { #[command(subcommand)] @@ -145,11 +152,11 @@ enum DatasetViewsCommands { #[derive(Debug, Clone, Args)] struct BootstrapCommonArgs { - /// Output file path. Defaults to views/.view.tsx. - #[arg(value_name = "PATH", conflicts_with = "file_flag")] - path: Option, + /// Custom view name. + #[arg(value_name = "NAME")] + name: String, - /// Output file path. Defaults to views/.view.tsx. + /// Output file or directory path. Defaults to braintrust-custom-views/.-view.tsx. #[arg(long = "file", env = "BT_VIEWS_BOOTSTRAP_FILE", value_name = "PATH")] file_flag: Option, @@ -197,6 +204,10 @@ struct DatasetViewBootstrapArgs { #[derive(Debug, Serialize)] struct BootstrapResult { path: String, + tsconfig_path: String, + tsconfig_created: bool, + types_path: String, + types_created: bool, view_type: ViewType, } @@ -206,7 +217,7 @@ struct PreviewCommonArgs { #[arg(value_name = "PATH")] path: PathBuf, - /// View slug or name when the file registers multiple views. + /// View slug or name to preview. #[arg(long, env = "BT_VIEWS_PREVIEW_VIEW")] view: Option, @@ -339,6 +350,8 @@ struct ViewsRuntimeContext { struct ViewsManifestFile { source_file: String, #[serde(default)] + dependencies: Vec, + #[serde(default)] entries: Vec, } @@ -349,8 +362,6 @@ struct ViewManifestEntry { slug: String, code: String, #[serde(default)] - description: Option, - #[serde(default)] project_id: Option, #[serde(default)] project_name: Option, @@ -358,10 +369,6 @@ struct ViewManifestEntry { dataset_id: Option, #[serde(default)] dataset_name: Option, - #[serde(default)] - metadata: Option, - #[serde(default)] - tags: Option>, } #[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] @@ -406,17 +413,20 @@ struct BtqlResponse { data: Vec>, } -#[derive(Clone)] struct PreviewServerState { client: ApiClient, project_id: Option, data: Value, + source: PreviewSource, + title: String, + dependency_paths: Mutex>, } struct PreviewContext { client: ApiClient, - project: Project, + project: Option, entry: ViewManifestEntry, + dependency_paths: Vec, } struct TracePreviewData { @@ -424,6 +434,15 @@ struct TracePreviewData { data: Value, } +#[derive(Clone)] +struct PreviewSource { + path: PathBuf, + view: Option, + view_type: Option, + runner: Option, + tsconfig: Option, +} + #[derive(Debug, Deserialize)] struct SpanFieldsRequest { #[serde(rename = "spanIds")] @@ -451,11 +470,15 @@ pub async fn run(base: BaseArgs, args: ViewsArgs) -> Result<()> { } fn bootstrap_trace(base: BaseArgs, args: TraceViewBootstrapArgs) -> Result<()> { - let path = write_bootstrap_file(args.common, "trace.view.tsx", TRACE_VIEW_BOOTSTRAP_TEMPLATE)?; - print_bootstrap_result(base.json, ViewType::Trace, &path) + let slug = bootstrap_slug(&args.common.name)?; + let default_file_name = format!("{slug}.trace-view.tsx"); + let content = trace_view_bootstrap_template(&args.common.name, &slug); + let result = write_bootstrap_scaffold(args.common, &default_file_name, &content)?; + print_bootstrap_result(base.json, ViewType::Trace, &result) } fn bootstrap_dataset(base: BaseArgs, args: DatasetViewBootstrapArgs) -> Result<()> { + let slug = bootstrap_slug(&args.common.name)?; let dataset_ref = match ( args.dataset_id.as_deref().map(str::trim), args.dataset.as_deref().map(str::trim), @@ -466,53 +489,183 @@ fn bootstrap_dataset(base: BaseArgs, args: DatasetViewBootstrapArgs) -> Result<( (_, Some(dataset)) if !dataset.is_empty() => format!("{{ name: {dataset:?} }}"), _ => "{ name: \"test-dataset\" }".to_string(), }; - let content = dataset_view_bootstrap_template(&dataset_ref); - let path = write_bootstrap_file(args.common, "dataset.view.tsx", &content)?; - print_bootstrap_result(base.json, ViewType::Dataset, &path) + let default_file_name = format!("{slug}.dataset-view.tsx"); + let content = dataset_view_bootstrap_template(&args.common.name, &slug, &dataset_ref); + let result = write_bootstrap_scaffold(args.common, &default_file_name, &content)?; + print_bootstrap_result(base.json, ViewType::Dataset, &result) } -fn write_bootstrap_file( +fn bootstrap_slug(name: &str) -> Result { + let mut slug = String::new(); + let mut pending_separator = false; + + for ch in name.trim().chars() { + if ch.is_ascii_alphanumeric() { + if pending_separator && !slug.is_empty() { + slug.push('-'); + } + slug.push(ch.to_ascii_lowercase()); + pending_separator = false; + } else if !slug.is_empty() { + pending_separator = true; + } + } + + if slug.is_empty() { + bail!("custom view name must contain at least one ASCII letter or number"); + } + Ok(slug) +} + +struct BootstrapWriteResult { + view_path: PathBuf, + tsconfig_path: PathBuf, + tsconfig_status: BootstrapSupportFileStatus, + types_path: PathBuf, + types_status: BootstrapSupportFileStatus, +} + +impl BootstrapWriteResult { + fn tsconfig_created(&self) -> bool { + matches!(self.tsconfig_status, BootstrapSupportFileStatus::Created) + } + + fn types_created(&self) -> bool { + matches!(self.types_status, BootstrapSupportFileStatus::Created) + } +} + +#[derive(Debug, Clone, Copy)] +enum BootstrapSupportFileStatus { + Created, + Reused, + Updated, +} + +impl BootstrapSupportFileStatus { + fn label(self) -> &'static str { + match self { + Self::Created => "Created", + Self::Reused => "Reused", + Self::Updated => "Updated", + } + } +} + +fn write_bootstrap_scaffold( args: BootstrapCommonArgs, default_file_name: &str, content: &str, -) -> Result { +) -> Result { let selected_path = args - .path - .or(args.file_flag) - .unwrap_or_else(|| PathBuf::from("views")); - let path = if selected_path.is_dir() || selected_path.extension().is_none() { - selected_path.join(default_file_name) + .file_flag + .unwrap_or_else(|| PathBuf::from(DEFAULT_CUSTOM_VIEWS_DIR)); + let (view_path, config_dir) = if selected_path.is_dir() || selected_path.extension().is_none() { + (selected_path.join(default_file_name), Some(selected_path)) } else { - selected_path + let config_dir = selected_path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .map(Path::to_path_buf); + (selected_path, config_dir) }; - let is_tsx_view_file = path + let tsconfig_path = config_dir + .as_ref() + .map(|dir| dir.join(CUSTOM_VIEW_TSCONFIG_FILE)) + .unwrap_or_else(|| PathBuf::from(CUSTOM_VIEW_TSCONFIG_FILE)); + let types_path = config_dir + .as_ref() + .map(|dir| dir.join(CUSTOM_VIEW_TYPES_FILE)) + .unwrap_or_else(|| PathBuf::from(CUSTOM_VIEW_TYPES_FILE)); + + let is_tsx_view_file = view_path .file_name() .and_then(|name| name.to_str()) - .is_some_and(|name| name.ends_with(".view.tsx")); + .is_some_and(|name| name.ends_with(".view.tsx") || name.ends_with("-view.tsx")); if !is_tsx_view_file { - bail!("custom view bootstrap path must end with .view.tsx"); + bail!("custom view bootstrap path must end with .view.tsx or -view.tsx"); } - if path.exists() && !args.force { + if view_path.exists() && !args.force { bail!( "custom view file already exists: {}. Use --force to overwrite.", - path.display() + view_path.display() ); } - if let Some(parent) = path + if let Some(parent) = view_path .parent() .filter(|parent| !parent.as_os_str().is_empty()) { std::fs::create_dir_all(parent) .with_context(|| format!("failed to create directory {}", parent.display()))?; } - std::fs::write(&path, content) - .with_context(|| format!("failed to write custom view file {}", path.display()))?; - Ok(path) + let tsconfig_exists = tsconfig_path.exists(); + if tsconfig_exists && !tsconfig_path.is_file() { + bail!( + "custom view tsconfig path already exists and is not a file: {}", + tsconfig_path.display() + ); + } + let types_exists = types_path.exists(); + if types_exists && !types_path.is_file() { + bail!( + "custom view types path already exists and is not a file: {}", + types_path.display() + ); + } + std::fs::write(&view_path, content) + .with_context(|| format!("failed to write custom view file {}", view_path.display()))?; + + let tsconfig_status = if !tsconfig_exists || args.force { + std::fs::write(&tsconfig_path, CUSTOM_VIEW_TSCONFIG_TEMPLATE).with_context(|| { + format!( + "failed to write custom view tsconfig {}", + tsconfig_path.display() + ) + })?; + if tsconfig_exists { + BootstrapSupportFileStatus::Updated + } else { + BootstrapSupportFileStatus::Created + } + } else { + BootstrapSupportFileStatus::Reused + }; + + let types_status = if !types_exists || args.force { + std::fs::write(&types_path, CUSTOM_VIEW_TYPES_TEMPLATE).with_context(|| { + format!("failed to write custom view types {}", types_path.display()) + })?; + if types_exists { + BootstrapSupportFileStatus::Updated + } else { + BootstrapSupportFileStatus::Created + } + } else { + BootstrapSupportFileStatus::Reused + }; + + Ok(BootstrapWriteResult { + view_path, + tsconfig_path, + tsconfig_status, + types_path, + types_status, + }) } -fn print_bootstrap_result(json_output: bool, view_type: ViewType, path: &Path) -> Result<()> { +fn print_bootstrap_result( + json_output: bool, + view_type: ViewType, + result: &BootstrapWriteResult, +) -> Result<()> { + let tsconfig_action = result.tsconfig_status.label(); + let types_action = result.types_status.label(); let result = BootstrapResult { - path: path.display().to_string(), + path: result.view_path.display().to_string(), + tsconfig_path: result.tsconfig_path.display().to_string(), + tsconfig_created: result.tsconfig_created(), + types_path: result.types_path.display().to_string(), + types_created: result.types_created(), view_type, }; if json_output { @@ -521,119 +674,253 @@ fn print_bootstrap_result(json_output: bool, view_type: ViewType, path: &Path) - println!( "Created {} custom view starter at {}", view_type.label(), - path.display() + result.path + ); + println!( + "{tsconfig_action} TypeScript config at {}", + result.tsconfig_path + ); + println!( + "{types_action} custom view type declarations at {}", + result.types_path ); println!( "Preview it with: bt views {} preview {}", view_type.label(), - path.display() + result.path ); } Ok(()) } -const TRACE_VIEW_BOOTSTRAP_TEMPLATE: &str = r##"import React from "react"; -import { - customTraceView, - formatJson, - type TraceViewProps, -} from "braintrust/custom-views"; +const CUSTOM_VIEW_TSCONFIG_TEMPLATE: &str = r#"{ + "compilerOptions": { + "target": "ES2020", + "lib": ["DOM", "DOM.Iterable", "ES2020"], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "allowJs": true, + "skipLibCheck": true, + "types": [] + }, + "include": [ + "**/*.view.ts", + "**/*.view.tsx", + "**/*.view.js", + "**/*.view.jsx", + "**/*-view.ts", + "**/*-view.tsx", + "**/*-view.js", + "**/*-view.jsx", + "**/*.d.ts" + ] +} +"#; + +const CUSTOM_VIEW_TYPES_TEMPLATE: &str = r#"type CustomViewElementProps = { + [propName: string]: unknown; +}; + +type CustomViewSelectChangeEvent = { + target: { value: string }; + currentTarget: { value: string }; + preventDefault: () => void; + stopPropagation: () => void; +}; + +interface CustomViewSelectProps extends CustomViewElementProps { + value?: string | number | readonly string[]; + defaultValue?: string | number | readonly string[]; + onChange?: (event: CustomViewSelectChangeEvent) => void; +} + +interface CustomViewOptionProps extends CustomViewElementProps { + value?: string | number; +} + +interface CustomViewIntrinsicElements { + select: CustomViewSelectProps; + option: CustomViewOptionProps; + [elementName: string]: CustomViewElementProps; +} + +declare namespace JSX { + interface IntrinsicElements extends CustomViewIntrinsicElements {} +} + +declare module "react" { + type ReactNode = any; + type ComponentType = (props: Props) => ReactNode; + type Dispatch = (value: Value) => void; + type SetStateAction = Value | ((previous: Value) => Value); + + const React: { + createElement: (...args: any[]) => ReactNode; + Fragment: any; + }; + + export default React; + export const Children: any; + export const Component: any; + export const Fragment: any; + export const Profiler: any; + export const PureComponent: any; + export const StrictMode: any; + export const Suspense: any; + export const cloneElement: (...args: any[]) => ReactNode; + export const createContext: (...args: any[]) => any; + export const createElement: (...args: any[]) => ReactNode; + export const createRef: (...args: any[]) => any; + export const forwardRef: (component: any) => any; + export const isValidElement: (value: any) => boolean; + export const lazy: (loader: any) => any; + export const memo: (component: ComponentType) => ComponentType; + export const startTransition: (callback: () => void) => void; + export const useCallback: any>(callback: Value, deps?: any[]) => Value; + export const useContext: (context: any) => any; + export const useDebugValue: (...args: any[]) => void; + export const useDeferredValue: (value: Value) => Value; + export const useEffect: (effect: () => void | (() => void), deps?: any[]) => void; + export const useId: () => string; + export const useImperativeHandle: (...args: any[]) => void; + export const useInsertionEffect: (effect: () => void | (() => void), deps?: any[]) => void; + export const useLayoutEffect: (effect: () => void | (() => void), deps?: any[]) => void; + export const useMemo: (factory: () => Value, deps?: any[]) => Value; + export const useReducer: (...args: any[]) => any; + export const useRef: (initialValue: Value) => { current: Value }; + export const useState: (initialValue: Value | (() => Value)) => [Value, Dispatch>]; + export const useSyncExternalStore: (...args: any[]) => any; + export const useTransition: () => [boolean, (callback: () => void) => void]; +} + +declare module "react/jsx-runtime" { + export namespace JSX { + interface IntrinsicElements extends CustomViewIntrinsicElements {} + } + + export const Fragment: any; + export const jsx: (...args: any[]) => any; + export const jsxs: (...args: any[]) => any; +} + +declare module "react/jsx-dev-runtime" { + export namespace JSX { + interface IntrinsicElements extends CustomViewIntrinsicElements {} + } + + export const Fragment: any; + export const jsxDEV: (...args: any[]) => any; +} +"#; + +const TRACE_VIEW_BOOTSTRAP_TEMPLATE: &str = r##"import { customTraceView } from "braintrust/custom-views"; function pretty(value: unknown) { - return value === undefined ? "" : formatJson(value); -} - -function StarterTraceView({ trace, span, selectSpan }: TraceViewProps) { - const spanIds = trace.spanOrder.slice(0, 100); - - return ( -
-
- Trace starter view - + return value === undefined ? "" : JSON.stringify(value, null, 2); +} + +export default customTraceView( + { + name: __VIEW_NAME__, + slug: __VIEW_SLUG__, + }, + ({ trace, span, selectSpan }) => { + const spanIds = trace.spanOrder.slice(0, 100); + + return ( +
+
+ Trace starter view + +
+ +
+
Selected span
+
row id: {span.id}
+
span id: {span.span_id}
+
children: {span.children.length}
+
+ +
+
+            {pretty(span.data.input)}
+          
+
+            {pretty(span.data.output)}
+          
+
- -
-
Selected span
-
row id: {span.id}
-
span id: {span.span_id}
-
children: {span.children.length}
-
- -
-
-          {pretty(span.data.input)}
-        
-
-          {pretty(span.data.output)}
-        
-
-
- ); -} - -customTraceView({ - name: "Starter Trace View", - slug: "starter-trace-view", - description: "Starter trace custom view generated by bt.", - component: StarterTraceView, -}); + ); + }, +); "##; -const DATASET_VIEW_BOOTSTRAP_TEMPLATE: &str = r##"import React from "react"; -import { - customDatasetView, - formatJson, - type DatasetViewProps, -} from "braintrust/custom-views"; +const DATASET_VIEW_BOOTSTRAP_TEMPLATE: &str = r##"import { customDatasetView } from "braintrust/custom-views"; function pretty(value: unknown) { - return value === undefined ? "" : formatJson(value); -} - -function StarterDatasetView({ id, input, expected, metadata, tags }: DatasetViewProps) { - return ( -
-
- Dataset starter view -
row id: {id}
- {tags.length > 0 ? ( -
tags: {tags.join(", ")}
- ) : null} + return value === undefined ? "" : JSON.stringify(value, null, 2); +} + +export default customDatasetView( + { + name: __VIEW_NAME__, + slug: __VIEW_SLUG__, + dataset: __DATASET_REF__, + }, + ({ id, input, expected, metadata, tags = [] }) => { + return ( +
+
+ Dataset starter view +
row id: {id}
+ {tags.length > 0 ? ( +
tags: {tags.join(", ")}
+ ) : null} +
+ +
+
+            {pretty(input)}
+          
+
+            {pretty(expected)}
+          
+
+            {pretty(metadata)}
+          
+
- -
-
-          {pretty(input)}
-        
-
-          {pretty(expected)}
-        
-
-          {pretty(metadata)}
-        
-
-
- ); -} - -customDatasetView({ - name: "Starter Dataset View", - slug: "starter-dataset-view", - description: "Starter dataset custom view generated by bt.", - dataset: __DATASET_REF__, - component: StarterDatasetView, -}); + ); + }, +); "##; -fn dataset_view_bootstrap_template(dataset_ref: &str) -> String { - DATASET_VIEW_BOOTSTRAP_TEMPLATE.replace("__DATASET_REF__", dataset_ref) +fn js_string_literal(value: &str) -> String { + serde_json::to_string(value).expect("strings serialize to JSON") +} + +fn trace_view_bootstrap_template(name: &str, slug: &str) -> String { + TRACE_VIEW_BOOTSTRAP_TEMPLATE + .replace("__VIEW_NAME__", &js_string_literal(name)) + .replace("__VIEW_SLUG__", &js_string_literal(slug)) +} + +fn dataset_view_bootstrap_template(name: &str, slug: &str, dataset_ref: &str) -> String { + DATASET_VIEW_BOOTSTRAP_TEMPLATE + .replace("__VIEW_NAME__", &js_string_literal(name)) + .replace("__VIEW_SLUG__", &js_string_literal(slug)) + .replace("__DATASET_REF__", dataset_ref) } async fn push(base: BaseArgs, args: ViewsPushArgs) -> Result<()> { @@ -641,7 +928,7 @@ async fn push(base: BaseArgs, args: ViewsPushArgs) -> Result<()> { let default_project = resolve_required_project(&base, &auth_ctx.client, true).await?; let files = collect_view_files(&args.resolved_paths())?; if files.is_empty() { - bail!("no custom view files found; expected files matching *.view.tsx, *.view.ts, *.view.jsx, or *.view.js"); + bail!("no custom view files found; expected files matching {VIEW_FILE_PATTERN_HELP}"); } let manifest = run_views_runner(args.runner.as_deref(), args.tsconfig.as_deref(), &files)?; @@ -703,12 +990,13 @@ async fn push(base: BaseArgs, args: ViewsPushArgs) -> Result<()> { } async fn preview(base: BaseArgs, args: ViewsPreviewArgs) -> Result<()> { - let context = resolve_preview_context(&base, &args.common, None).await?; + let context = + resolve_preview_context(&base, &args.common, None, args.trace.url.as_deref()).await?; match context.entry.view_type { ViewType::Trace => { let trace_data = build_trace_preview_data( &context.client, - &context.project, + context.project.as_ref(), &args.trace, args.print_queries, ) @@ -719,24 +1007,26 @@ async fn preview(base: BaseArgs, args: ViewsPreviewArgs) -> Result<()> { context.client, Some(trace_data.project_id), context.entry, + context.dependency_paths, trace_data.data, ) .await } ViewType::Dataset => { - let preview_data = build_dataset_preview_data( - &context.client, - &context.project, - &context.entry, - &args.dataset, - ) - .await?; + let project = context + .project + .as_ref() + .ok_or_else(|| anyhow!("dataset preview requires a project"))?; + let preview_data = + build_dataset_preview_data(&context.client, project, &context.entry, &args.dataset) + .await?; serve_preview( base, &args.common, context.client, None, context.entry, + context.dependency_paths, preview_data, ) .await @@ -745,10 +1035,16 @@ async fn preview(base: BaseArgs, args: ViewsPreviewArgs) -> Result<()> { } async fn preview_trace(base: BaseArgs, args: TraceViewPreviewArgs) -> Result<()> { - let context = resolve_preview_context(&base, &args.common, Some(ViewType::Trace)).await?; + let context = resolve_preview_context( + &base, + &args.common, + Some(ViewType::Trace), + args.target.url.as_deref(), + ) + .await?; let trace_data = build_trace_preview_data( &context.client, - &context.project, + context.project.as_ref(), &args.target, args.print_queries, ) @@ -759,26 +1055,28 @@ async fn preview_trace(base: BaseArgs, args: TraceViewPreviewArgs) -> Result<()> context.client, Some(trace_data.project_id), context.entry, + context.dependency_paths, trace_data.data, ) .await } async fn preview_dataset(base: BaseArgs, args: DatasetViewPreviewArgs) -> Result<()> { - let context = resolve_preview_context(&base, &args.common, Some(ViewType::Dataset)).await?; - let preview_data = build_dataset_preview_data( - &context.client, - &context.project, - &context.entry, - &args.target, - ) - .await?; + let context = + resolve_preview_context(&base, &args.common, Some(ViewType::Dataset), None).await?; + let project = context + .project + .as_ref() + .ok_or_else(|| anyhow!("dataset preview requires a project"))?; + let preview_data = + build_dataset_preview_data(&context.client, project, &context.entry, &args.target).await?; serve_preview( base, &args.common, context.client, None, context.entry, + context.dependency_paths, preview_data, ) .await @@ -788,20 +1086,46 @@ async fn resolve_preview_context( base: &BaseArgs, args: &PreviewCommonArgs, view_type: Option, + trace_url: Option<&str>, ) -> Result { + if !args.path.exists() { + bail!("custom view file not found: {}", args.path.display()); + } + if !args.path.is_file() { + bail!( + "custom view preview requires a single view file; got {}", + args.path.display() + ); + } + if !is_view_file(&args.path) { + bail!("custom view preview path must match {VIEW_FILE_PATTERN_HELP}"); + } let files = vec![args.path.clone()]; let manifest = run_views_runner(args.runner.as_deref(), args.tsconfig.as_deref(), &files)?; validate_manifest_runtime(&manifest)?; let entry = select_preview_entry(&manifest, args.view.as_deref(), view_type)?.clone(); + let dependency_paths = preview_dependency_paths(&manifest); let auth_ctx = auth::login_read_only(&base).await?; let client = ApiClient::new(&auth_ctx)?; - let default_project = resolve_required_project(&base, &client, true).await?; - let project = resolve_project_for_entry(&client, &default_project, &entry).await?; + let project = if trace_url_supplies_project(entry.view_type, trace_url) { + None + } else if entry.view_type == ViewType::Trace && trace_url.is_some() { + match resolve_project_optional(&base, &client, true).await? { + Some(default_project) => { + Some(resolve_project_for_entry(&client, &default_project, &entry).await?) + } + None => None, + } + } else { + let default_project = resolve_required_project(&base, &client, true).await?; + Some(resolve_project_for_entry(&client, &default_project, &entry).await?) + }; Ok(PreviewContext { client, project, entry, + dependency_paths, }) } @@ -811,6 +1135,7 @@ async fn serve_preview( client: ApiClient, trace_project_id: Option, entry: ViewManifestEntry, + dependency_paths: Vec, preview_data: Value, ) -> Result<()> { let listener = TcpListener::bind(("127.0.0.1", args.port)) @@ -823,17 +1148,21 @@ async fn serve_preview( client, project_id: trace_project_id, data: preview_data, + source: PreviewSource { + path: args.path.clone(), + view: args.view.clone(), + view_type: Some(entry.view_type), + runner: args.runner.clone(), + tsconfig: args.tsconfig.clone(), + }, + title: entry.name.clone(), + dependency_paths: Mutex::new(dependency_paths), }); - let code = entry.code.clone(); - let name = entry.name.clone(); let server = HttpServer::new(move || { App::new() .app_data(state.clone()) - .app_data(web::Data::new(PreviewPage { - title: name.clone(), - code: code.clone(), - })) .route("/", web::get().to(preview_index)) + .route("/preview-version", web::get().to(preview_version)) .route("/span-fields", web::post().to(preview_span_fields)) }) .workers(1) @@ -869,22 +1198,44 @@ async fn serve_preview( Ok(()) } -#[derive(Clone)] struct PreviewPage { title: String, code: String, + dependency_paths: Vec, } -async fn preview_index( - state: web::Data, - page: web::Data, -) -> HttpResponse { - let html = render_preview_html(&page.title, &page.code, &state.data); +async fn preview_index(state: web::Data) -> HttpResponse { + let dependency_paths = state + .dependency_paths + .lock() + .map(|paths| paths.clone()) + .unwrap_or_default(); + let version = preview_source_version(&state.source, &dependency_paths); + let html = match load_preview_page(&state.source) { + Ok(page) => { + if let Ok(mut dependency_paths) = state.dependency_paths.lock() { + *dependency_paths = page.dependency_paths.clone(); + } + render_preview_html(&page.title, &page.code, &state.data, &version) + } + Err(err) => render_preview_error_html(&state.title, &format!("{err:#}"), &version), + }; HttpResponse::Ok() .content_type("text/html; charset=utf-8") .body(html) } +async fn preview_version(state: web::Data) -> HttpResponse { + let dependency_paths = state + .dependency_paths + .lock() + .map(|paths| paths.clone()) + .unwrap_or_default(); + HttpResponse::Ok().json(json!({ + "version": preview_source_version(&state.source, &dependency_paths), + })) +} + async fn preview_span_fields( state: web::Data, body: web::Json, @@ -927,10 +1278,66 @@ async fn preview_span_fields( HttpResponse::Ok().json(Value::Object(response)) } -fn render_preview_html(title: &str, code: &str, data: &Value) -> String { +fn load_preview_page(source: &PreviewSource) -> Result { + let files = vec![source.path.clone()]; + let manifest = run_views_runner(source.runner.as_deref(), source.tsconfig.as_deref(), &files)?; + validate_manifest_runtime(&manifest)?; + let entry = select_preview_entry(&manifest, source.view.as_deref(), source.view_type)?; + let dependency_paths = preview_dependency_paths(&manifest); + Ok(PreviewPage { + title: entry.name.clone(), + code: entry.code.clone(), + dependency_paths, + }) +} + +fn preview_dependency_paths(manifest: &ViewsManifest) -> Vec { + let mut paths = BTreeSet::new(); + for file in &manifest.files { + paths.insert(file.source_file.clone()); + for dependency in &file.dependencies { + paths.insert(dependency.clone()); + } + } + paths.into_iter().map(PathBuf::from).collect() +} + +fn preview_source_version(source: &PreviewSource, dependency_paths: &[PathBuf]) -> String { + let mut parts = Vec::new(); + push_preview_file_version(&mut parts, &source.path); + for dependency in dependency_paths { + if dependency != &source.path { + push_preview_file_version(&mut parts, dependency); + } + } + if let Some(tsconfig) = &source.tsconfig { + push_preview_file_version(&mut parts, tsconfig); + } + parts.join("|") +} + +fn push_preview_file_version(parts: &mut Vec, path: &Path) { + match std::fs::metadata(path) { + Ok(metadata) => { + let modified = metadata + .modified() + .ok() + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_nanos()) + .unwrap_or(0); + parts.push(format!("{}:{modified}:{}", path.display(), metadata.len())); + } + Err(err) => { + parts.push(format!("{}:error:{err}", path.display())); + } + } +} + +fn render_preview_html(title: &str, code: &str, data: &Value, version: &str) -> String { let data_json = script_json(data); let code_json = script_json(&Value::String(code.to_string())); let title_json = script_json(&Value::String(title.to_string())); + let version_json = script_json(&Value::String(version.to_string())); format!( r##" @@ -950,6 +1357,7 @@ fn render_preview_html(title: &str, code: &str, data: &Value) -> String {
+ +"##, + html_escape(title), + ) +} + +fn render_preview_error_html(title: &str, error: &str, version: &str) -> String { + let title_json = script_json(&Value::String(title.to_string())); + let error_json = script_json(&Value::String(error.to_string())); + let version_json = script_json(&Value::String(version.to_string())); + format!( + r##" + + + + + {} + + + +

+  
 
 "##,
@@ -1126,9 +1584,18 @@ fn is_view_file(path: &Path) -> bool {
     let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
         return false;
     };
-    [".view.tsx", ".view.ts", ".view.jsx", ".view.js"]
-        .iter()
-        .any(|suffix| name.ends_with(suffix))
+    [
+        ".view.tsx",
+        ".view.ts",
+        ".view.jsx",
+        ".view.js",
+        "-view.tsx",
+        "-view.ts",
+        "-view.jsx",
+        "-view.js",
+    ]
+    .iter()
+    .any(|suffix| name.ends_with(suffix))
 }
 
 fn run_views_runner(
@@ -1338,10 +1805,6 @@ fn build_insert_event(view: &PreparedView, if_exists: IfExistsMode) -> Value {
     );
     object.insert("name".to_string(), Value::String(view.entry.name.clone()));
     object.insert("slug".to_string(), Value::String(view.entry.slug.clone()));
-    object.insert(
-        "description".to_string(),
-        Value::String(view.entry.description.clone().unwrap_or_default()),
-    );
     object.insert(
         "function_type".to_string(),
         Value::String("custom_view".to_string()),
@@ -1365,15 +1828,6 @@ fn build_insert_event(view: &PreparedView, if_exists: IfExistsMode) -> Value {
         }),
     );
 
-    if let Some(metadata) = &view.entry.metadata {
-        object.insert("metadata".to_string(), metadata.clone());
-    }
-    if let Some(tags) = &view.entry.tags {
-        object.insert(
-            "tags".to_string(),
-            Value::Array(tags.iter().cloned().map(Value::String).collect()),
-        );
-    }
     if let Some(dataset) = &view.dataset {
         object.insert(
             "origin".to_string(),
@@ -1564,7 +2018,7 @@ async fn resolve_dataset_by_selector(
 
 async fn build_trace_preview_data(
     client: &ApiClient,
-    project: &Project,
+    project: Option<&Project>,
     args: &TracePreviewTargetArgs,
     print_queries: bool,
 ) -> Result {
@@ -1600,14 +2054,20 @@ struct TracePreviewTarget {
 
 async fn resolve_trace_preview_target(
     client: &ApiClient,
-    default_project: &Project,
+    default_project: Option<&Project>,
     args: &TracePreviewTargetArgs,
 ) -> Result {
     if let Some(url) = args.url.as_deref() {
         let parsed = parse_trace_url(url)?;
         let project_id = match parsed.project.as_deref() {
-            Some(project) if project == default_project.id || project == default_project.name => {
-                default_project.id.clone()
+            Some(project)
+                if default_project
+                    .map(|default_project| {
+                        project == default_project.id || project == default_project.name
+                    })
+                    .unwrap_or(false) =>
+            {
+                default_project.expect("checked above").id.clone()
             }
             Some(project) if is_uuid_like(project) => project.to_string(),
             Some(project) => {
@@ -1616,7 +2076,13 @@ async fn resolve_trace_preview_target(
                     .ok_or_else(|| anyhow!("project '{project}' from trace URL not found"))?
                     .id
             }
-            None => default_project.id.clone(),
+            None => default_project
+                .map(|project| project.id.clone())
+                .ok_or_else(|| {
+                    anyhow!(
+                        "trace URL must include a project path like /app//p//... or a project must be supplied"
+                    )
+                })?,
         };
         let root_span_id = parsed
             .row_ref
@@ -1640,7 +2106,10 @@ async fn resolve_trace_preview_target(
     } else {
         args.project_id
             .clone()
-            .unwrap_or_else(|| default_project.id.clone())
+            .or_else(|| default_project.map(|project| project.id.clone()))
+            .ok_or_else(|| {
+                anyhow!("trace preview requires --project-id or --project when --trace-id is used")
+            })?
     };
     let root_span_id = args
         .trace_id
@@ -1685,6 +2154,14 @@ fn parse_trace_url(input: &str) -> Result {
     Ok(parsed)
 }
 
+fn trace_url_supplies_project(view_type: ViewType, trace_url: Option<&str>) -> bool {
+    view_type == ViewType::Trace
+        && trace_url
+            .and_then(|url| parse_trace_url(url).ok())
+            .and_then(|parsed| parsed.project)
+            .is_some()
+}
+
 fn is_uuid_like(value: &str) -> bool {
     let bytes = value.as_bytes();
     if bytes.len() != 36 {
@@ -1921,13 +2398,224 @@ fn fields_from_row(row: Map, fields: Option<&[String]>) -> Value
 mod tests {
     use super::*;
 
+    fn test_client() -> ApiClient {
+        let login = braintrust_sdk_rust::LoginState::new();
+        login.set(
+            "sk-test".to_string(),
+            "org_test".to_string(),
+            "test-org".to_string(),
+            "http://127.0.0.1:1".to_string(),
+            "http://127.0.0.1:1".to_string(),
+        );
+        ApiClient::new(&crate::auth::LoginContext {
+            login,
+            api_url: "http://127.0.0.1:1".to_string(),
+            app_url: "http://127.0.0.1:1".to_string(),
+        })
+        .expect("test client")
+    }
+
+    fn test_project() -> Project {
+        Project {
+            id: "proj_test".to_string(),
+            name: "test-project".to_string(),
+            org_id: "org_test".to_string(),
+            description: None,
+        }
+    }
+
     #[test]
     fn detects_view_files() {
         assert!(is_view_file(Path::new("conversation.view.tsx")));
         assert!(is_view_file(Path::new("dataset.view.js")));
+        assert!(is_view_file(Path::new("conversation.trace-view.tsx")));
+        assert!(is_view_file(Path::new("dataset.dataset-view.js")));
         assert!(!is_view_file(Path::new("regular.tsx")));
     }
 
+    #[test]
+    fn preview_html_includes_hot_reload_polling() {
+        let html = render_preview_html(
+            "Preview",
+            "module.exports = function View() {}",
+            &json!({}),
+            "v1",
+        );
+
+        assert!(html.contains("previewVersion"));
+        assert!(html.contains("/preview-version"));
+        assert!(html.contains("window.location.reload()"));
+    }
+
+    #[test]
+    fn preview_error_html_includes_hot_reload_polling() {
+        let html = render_preview_error_html("Preview", "bundle failed", "v1");
+
+        assert!(html.contains("bundle failed"));
+        assert!(html.contains("/preview-version"));
+        assert!(html.contains("window.location.reload()"));
+    }
+
+    #[test]
+    fn preview_source_version_changes_when_source_changes() {
+        let dir = tempfile::tempdir().expect("tempdir");
+        let path = dir.path().join("test.trace-view.tsx");
+        std::fs::write(&path, "export default null;\n").expect("write view");
+        let source = PreviewSource {
+            path: path.clone(),
+            view: None,
+            view_type: Some(ViewType::Trace),
+            runner: None,
+            tsconfig: None,
+        };
+
+        let before = preview_source_version(&source, &[]);
+        std::fs::write(&path, "export default function View() { return null; }\n")
+            .expect("rewrite view");
+        let after = preview_source_version(&source, &[]);
+
+        assert_ne!(before, after);
+    }
+
+    #[test]
+    fn preview_source_version_changes_when_dependency_changes() {
+        let dir = tempfile::tempdir().expect("tempdir");
+        let path = dir.path().join("test.trace-view.tsx");
+        let dependency = dir.path().join("component.tsx");
+        std::fs::write(&path, "export { default } from './component';\n").expect("write view");
+        std::fs::write(
+            &dependency,
+            "export default function View() { return null; }\n",
+        )
+        .expect("write dependency");
+        let source = PreviewSource {
+            path,
+            view: None,
+            view_type: Some(ViewType::Trace),
+            runner: None,
+            tsconfig: None,
+        };
+
+        let dependency_paths = vec![dependency.clone()];
+        let before = preview_source_version(&source, &dependency_paths);
+        std::fs::write(
+            &dependency,
+            "export default function View() { return 'changed'; }\n",
+        )
+        .expect("rewrite dependency");
+        let after = preview_source_version(&source, &dependency_paths);
+
+        assert_ne!(before, after);
+    }
+
+    #[test]
+    fn parses_trace_preview_url_project_and_span_selectors() {
+        let parsed = parse_trace_url(
+            "https://www.braintrust.dev/app/test-org/p/test-project/logs?r=root-span&s=selected-span",
+        )
+        .expect("parse URL");
+
+        assert_eq!(parsed.project.as_deref(), Some("test-project"));
+        assert_eq!(parsed.row_ref.as_deref(), Some("root-span"));
+        assert_eq!(parsed.span_id.as_deref(), Some("selected-span"));
+    }
+
+    #[test]
+    fn trace_url_project_only_skips_project_resolution_for_trace_views() {
+        let url = Some("https://www.braintrust.dev/app/test-org/p/test-project/logs?r=root-span");
+
+        assert!(trace_url_supplies_project(ViewType::Trace, url));
+        assert!(!trace_url_supplies_project(ViewType::Dataset, url));
+        assert!(!trace_url_supplies_project(
+            ViewType::Trace,
+            Some("https://www.braintrust.dev/app/test-org/logs?r=root-span")
+        ));
+        assert!(!trace_url_supplies_project(ViewType::Trace, None));
+    }
+
+    #[tokio::test]
+    async fn trace_preview_url_project_id_does_not_require_default_project() {
+        let client = test_client();
+        let project_id = "00000000-0000-4000-8000-000000000001";
+        let target = resolve_trace_preview_target(
+            &client,
+            None,
+            &TracePreviewTargetArgs {
+                url: Some(format!(
+                    "https://www.braintrust.dev/app/test-org/p/{project_id}/logs?r=root-span&s=selected-span"
+                )),
+                object_ref: None,
+                project_id: None,
+                trace_id: None,
+                span_id: None,
+            },
+        )
+        .await
+        .expect("resolve target");
+
+        assert_eq!(target.project_id, project_id);
+        assert_eq!(target.root_span_id, "root-span");
+        assert_eq!(target.span_id.as_deref(), Some("selected-span"));
+    }
+
+    #[tokio::test]
+    async fn trace_preview_url_without_project_uses_default_project() {
+        let client = test_client();
+        let project = test_project();
+        let target = resolve_trace_preview_target(
+            &client,
+            Some(&project),
+            &TracePreviewTargetArgs {
+                url: Some("https://www.braintrust.dev/app/test-org/logs?r=root-span".to_string()),
+                object_ref: None,
+                project_id: None,
+                trace_id: None,
+                span_id: Some("selected-span".to_string()),
+            },
+        )
+        .await
+        .expect("resolve target");
+
+        assert_eq!(target.project_id, "proj_test");
+        assert_eq!(target.root_span_id, "root-span");
+        assert_eq!(target.span_id.as_deref(), Some("selected-span"));
+    }
+
+    #[tokio::test]
+    async fn trace_preview_url_without_project_errors_without_default_project() {
+        let client = test_client();
+        let err = resolve_trace_preview_target(
+            &client,
+            None,
+            &TracePreviewTargetArgs {
+                url: Some("https://www.braintrust.dev/app/test-org/logs?r=root-span".to_string()),
+                object_ref: None,
+                project_id: None,
+                trace_id: None,
+                span_id: None,
+            },
+        )
+        .await
+        .expect_err("missing project should fail");
+
+        assert!(err
+            .to_string()
+            .contains("trace URL must include a project path"));
+    }
+
+    #[test]
+    fn normalizes_bootstrap_name_to_slug() {
+        assert_eq!(
+            bootstrap_slug("Trace Review 2026").unwrap(),
+            "trace-review-2026"
+        );
+        assert_eq!(
+            bootstrap_slug("  Dataset__Review!! ").unwrap(),
+            "dataset-review"
+        );
+        assert!(bootstrap_slug("!!!").is_err());
+    }
+
     #[test]
     fn builds_inline_custom_view_insert_event() {
         let view = PreparedView {
@@ -1937,20 +2625,12 @@ mod tests {
                 name: "Test View".to_string(),
                 slug: "test-view".to_string(),
                 code: "module.exports = function View() {}".to_string(),
-                description: Some("desc".to_string()),
                 project_id: None,
                 project_name: None,
                 dataset_id: None,
                 dataset_name: None,
-                metadata: Some(json!({ "kind": "test" })),
-                tags: Some(vec!["review".to_string()]),
-            },
-            project: Project {
-                id: "proj_test".to_string(),
-                name: "test-project".to_string(),
-                org_id: "org_test".to_string(),
-                description: None,
             },
+            project: test_project(),
             dataset: None,
         };
 
@@ -1964,6 +2644,9 @@ mod tests {
             "browser"
         );
         assert!(event.get("origin").is_none());
+        assert!(event.get("description").is_none());
+        assert!(event.get("metadata").is_none());
+        assert!(event.get("tags").is_none());
     }
 
     #[test]
@@ -1975,20 +2658,12 @@ mod tests {
                 name: "Dataset View".to_string(),
                 slug: "dataset-view".to_string(),
                 code: "module.exports = function View() {}".to_string(),
-                description: None,
                 project_id: None,
                 project_name: None,
                 dataset_id: Some("dataset_test".to_string()),
                 dataset_name: None,
-                metadata: None,
-                tags: None,
-            },
-            project: Project {
-                id: "proj_test".to_string(),
-                name: "test-project".to_string(),
-                org_id: "org_test".to_string(),
-                description: None,
             },
+            project: test_project(),
             dataset: Some(datasets_api::Dataset {
                 id: "dataset_test".to_string(),
                 name: "test-dataset".to_string(),
diff --git a/tests/cli.rs b/tests/cli.rs
index 6ead7d7..ced942c 100644
--- a/tests/cli.rs
+++ b/tests/cli.rs
@@ -168,15 +168,61 @@ fn views_trace_bootstrap_creates_starter_file() {
 
     bt_command()
         .current_dir(dir.path())
-        .args(["views", "trace", "bootstrap"])
+        .args(["views", "trace", "bootstrap", "Trace Review"])
         .assert()
         .success()
-        .stdout(predicate::str::contains("views/trace.view.tsx"));
+        .stdout(predicate::str::contains(
+            "braintrust-custom-views/trace-review.trace-view.tsx",
+        ))
+        .stdout(predicate::str::contains(
+            "braintrust-custom-views/tsconfig.json",
+        ))
+        .stdout(predicate::str::contains(
+            "braintrust-custom-views/custom-view-env.d.ts",
+        ));
 
-    let contents =
-        fs::read_to_string(dir.path().join("views/trace.view.tsx")).expect("read starter view");
-    assert!(contents.contains("customTraceView"));
-    assert!(contents.contains("Starter Trace View"));
+    let contents = fs::read_to_string(
+        dir.path()
+            .join("braintrust-custom-views/trace-review.trace-view.tsx"),
+    )
+    .expect("read starter view");
+    assert!(contents.contains("export default customTraceView"));
+    assert!(contents.contains(r#"name: "Trace Review""#));
+    assert!(contents.contains(r#"slug: "trace-review""#));
+    assert!(contents.contains("({ trace, span, selectSpan }) => {"));
+    assert!(contents.contains("onChange={(event) => selectSpan?.(event.target.value)}"));
+    assert!(!contents.contains("function StarterTraceView"));
+    assert!(!contents.contains("StarterTraceView,"));
+    assert!(!contents.contains("trace: { spanOrder: string[] }"));
+    assert!(!contents.contains("event:"));
+    assert!(!contents.contains(r#"from "react""#));
+    assert!(!contents.contains("component: StarterTraceView"));
+
+    let tsconfig = fs::read_to_string(dir.path().join("braintrust-custom-views/tsconfig.json"))
+        .expect("read custom view tsconfig");
+    assert!(tsconfig.contains(r#""jsx": "react-jsx""#));
+    assert!(tsconfig.contains(r#""moduleResolution": "Bundler""#));
+    assert!(tsconfig.contains(r#""noEmit": true"#));
+    assert!(tsconfig.contains(r#""allowJs": true"#));
+    assert!(tsconfig.contains(r#""**/*.view.tsx""#));
+    assert!(tsconfig.contains(r#""**/*.view.jsx""#));
+    assert!(tsconfig.contains(r#""**/*-view.tsx""#));
+    assert!(tsconfig.contains(r#""**/*-view.jsx""#));
+    assert!(tsconfig.contains(r#""**/*.d.ts""#));
+
+    let types = fs::read_to_string(
+        dir.path()
+            .join("braintrust-custom-views/custom-view-env.d.ts"),
+    )
+    .expect("read custom view type declarations");
+    assert!(types.contains("declare namespace JSX"));
+    assert!(types.contains("interface CustomViewIntrinsicElements"));
+    assert!(types.contains("select: CustomViewSelectProps"));
+    assert!(types.contains("option: CustomViewOptionProps"));
+    assert!(types.contains("onChange?: (event: CustomViewSelectChangeEvent) => void"));
+    assert!(types.contains("interface IntrinsicElements extends CustomViewIntrinsicElements"));
+    assert!(types.contains(r#"declare module "react/jsx-runtime""#));
+    assert!(types.contains(r#"declare module "react""#));
 }
 
 #[test]
@@ -189,6 +235,7 @@ fn views_dataset_bootstrap_creates_starter_file_with_dataset_name() {
             "views",
             "dataset",
             "bootstrap",
+            "Dataset Review",
             "--dataset",
             "test-dataset",
             "--file",
@@ -200,8 +247,152 @@ fn views_dataset_bootstrap_creates_starter_file_with_dataset_name() {
 
     let contents =
         fs::read_to_string(dir.path().join("custom.dataset.view.tsx")).expect("read starter view");
-    assert!(contents.contains("customDatasetView"));
+    assert!(contents.contains("export default customDatasetView"));
+    assert!(contents.contains(r#"name: "Dataset Review""#));
+    assert!(contents.contains(r#"slug: "dataset-review""#));
     assert!(contents.contains(r#"dataset: { name: "test-dataset" }"#));
+    assert!(contents.contains("({ id, input, expected, metadata, tags = [] }) => {"));
+    assert!(!contents.contains("function StarterDatasetView"));
+    assert!(!contents.contains("StarterDatasetView,"));
+    assert!(!contents.contains("id: string;"));
+    assert!(!contents.contains(r#"from "react""#));
+    assert!(!contents.contains("component: StarterDatasetView"));
+
+    let tsconfig =
+        fs::read_to_string(dir.path().join("tsconfig.json")).expect("read custom view tsconfig");
+    assert!(tsconfig.contains(r#""jsx": "react-jsx""#));
+    assert!(dir.path().join("custom-view-env.d.ts").exists());
+}
+
+#[test]
+fn views_bootstrap_json_reports_tsconfig() {
+    let dir = tempfile::tempdir().expect("tempdir");
+
+    let output = bt_command()
+        .current_dir(dir.path())
+        .args(["views", "--json", "trace", "bootstrap", "Trace Review"])
+        .assert()
+        .success()
+        .get_output()
+        .stdout
+        .clone();
+    let payload: serde_json::Value =
+        serde_json::from_slice(&output).expect("parse bootstrap json output");
+
+    assert_eq!(
+        payload["path"],
+        "braintrust-custom-views/trace-review.trace-view.tsx"
+    );
+    assert_eq!(
+        payload["tsconfig_path"],
+        "braintrust-custom-views/tsconfig.json"
+    );
+    assert_eq!(payload["tsconfig_created"], true);
+    assert_eq!(
+        payload["types_path"],
+        "braintrust-custom-views/custom-view-env.d.ts"
+    );
+    assert_eq!(payload["types_created"], true);
+    assert_eq!(payload["view_type"], "trace");
+}
+
+#[test]
+fn views_bootstrap_preserves_existing_tsconfig_without_force() {
+    let dir = tempfile::tempdir().expect("tempdir");
+    fs::create_dir_all(dir.path().join("custom")).expect("create custom view dir");
+    fs::write(
+        dir.path().join("custom/tsconfig.json"),
+        "{ \"compilerOptions\": { \"jsx\": \"preserve\" } }\n",
+    )
+    .expect("write existing tsconfig");
+    fs::write(
+        dir.path().join("custom/custom-view-env.d.ts"),
+        "declare const preserved: true;\n",
+    )
+    .expect("write existing types");
+
+    bt_command()
+        .current_dir(dir.path())
+        .args([
+            "views",
+            "trace",
+            "bootstrap",
+            "Trace Review",
+            "--file",
+            "custom",
+        ])
+        .assert()
+        .success()
+        .stdout(predicate::str::contains("Reused TypeScript config"))
+        .stdout(predicate::str::contains(
+            "Reused custom view type declarations",
+        ));
+
+    let tsconfig =
+        fs::read_to_string(dir.path().join("custom/tsconfig.json")).expect("read tsconfig");
+    assert_eq!(
+        tsconfig,
+        "{ \"compilerOptions\": { \"jsx\": \"preserve\" } }\n"
+    );
+    assert!(dir
+        .path()
+        .join("custom/trace-review.trace-view.tsx")
+        .exists());
+    let types =
+        fs::read_to_string(dir.path().join("custom/custom-view-env.d.ts")).expect("read types");
+    assert_eq!(types, "declare const preserved: true;\n");
+}
+
+#[test]
+fn views_bootstrap_force_overwrites_view_and_tsconfig() {
+    let dir = tempfile::tempdir().expect("tempdir");
+    fs::create_dir_all(dir.path().join("custom")).expect("create custom view dir");
+    fs::write(
+        dir.path().join("custom/trace-review.trace-view.tsx"),
+        "old view\n",
+    )
+    .expect("write view");
+    fs::write(
+        dir.path().join("custom/tsconfig.json"),
+        "{ \"compilerOptions\": { \"jsx\": \"preserve\" } }\n",
+    )
+    .expect("write existing tsconfig");
+    fs::write(
+        dir.path().join("custom/custom-view-env.d.ts"),
+        "declare const preserved: true;\n",
+    )
+    .expect("write existing types");
+
+    bt_command()
+        .current_dir(dir.path())
+        .args([
+            "views",
+            "trace",
+            "bootstrap",
+            "Trace Review",
+            "--file",
+            "custom",
+            "--force",
+        ])
+        .assert()
+        .success()
+        .stdout(predicate::str::contains("Updated TypeScript config"))
+        .stdout(predicate::str::contains(
+            "Updated custom view type declarations",
+        ));
+
+    let contents = fs::read_to_string(dir.path().join("custom/trace-review.trace-view.tsx"))
+        .expect("read starter view");
+    assert!(contents.contains("export default customTraceView"));
+
+    let tsconfig =
+        fs::read_to_string(dir.path().join("custom/tsconfig.json")).expect("read tsconfig");
+    assert!(tsconfig.contains(r#""jsx": "react-jsx""#));
+    assert!(!tsconfig.contains(r#""jsx": "preserve""#));
+    let types =
+        fs::read_to_string(dir.path().join("custom/custom-view-env.d.ts")).expect("read types");
+    assert!(types.contains(r#"declare module "react/jsx-runtime""#));
+    assert!(!types.contains("declare const preserved"));
 }
 
 #[test]

From bfb9ea97f4e4895d46eb58420cbce6a34baf3ff9 Mon Sep 17 00:00:00 2001
From: Luca Forstner 
Date: Fri, 3 Jul 2026 15:46:59 +0200
Subject: [PATCH 3/6] no aliases

---
 src/views.rs |  2 --
 tests/cli.rs | 10 ++++++++++
 2 files changed, 10 insertions(+), 2 deletions(-)

diff --git a/src/views.rs b/src/views.rs
index f67a261..beadce5 100644
--- a/src/views.rs
+++ b/src/views.rs
@@ -57,10 +57,8 @@ enum ViewsCommands {
     /// Push local custom view definitions
     Push(ViewsPushArgs),
     /// Work with trace custom views
-    #[command(visible_alias = "traces")]
     Trace(TraceViewsArgs),
     /// Work with dataset custom views
-    #[command(visible_alias = "datasets")]
     Dataset(DatasetViewsArgs),
     /// Preview one custom view locally
     #[command(hide = true)]
diff --git a/tests/cli.rs b/tests/cli.rs
index ced942c..e96ae3e 100644
--- a/tests/cli.rs
+++ b/tests/cli.rs
@@ -108,6 +108,16 @@ fn views_help_accepts_push_trace_and_dataset_subcommands() {
         .stdout(predicate::str::contains("dataset"));
 }
 
+#[test]
+fn views_rejects_trace_and_dataset_aliases() {
+    for alias in ["traces", "datasets"] {
+        bt_command()
+            .args(["views", alias, "--help"])
+            .assert()
+            .failure();
+    }
+}
+
 #[test]
 fn views_trace_help_lists_bootstrap_and_preview() {
     bt_command()

From 18790b73c60160155334a316915928101dd3e85c Mon Sep 17 00:00:00 2001
From: Luca Forstner 
Date: Tue, 7 Jul 2026 13:23:55 +0200
Subject: [PATCH 4/6] simplify tools

---
 scripts/views-runner.ts |  22 +----
 src/views.rs            | 175 ++++------------------------------------
 tests/cli.rs            |  11 ++-
 3 files changed, 24 insertions(+), 184 deletions(-)

diff --git a/scripts/views-runner.ts b/scripts/views-runner.ts
index c9cf291..46fa42e 100644
--- a/scripts/views-runner.ts
+++ b/scripts/views-runner.ts
@@ -105,18 +105,6 @@ async function loadEsbuild(sourceFiles: string[]): Promise {
   );
 }
 
-function loadTsconfigPath(): string | undefined {
-  const tsNode = process.env.TS_NODE_PROJECT?.trim();
-  if (tsNode) {
-    return tsNode;
-  }
-  const tsx = process.env.TSX_TSCONFIG_PATH?.trim();
-  if (tsx) {
-    return tsx;
-  }
-  return undefined;
-}
-
 function sdkPath(): string {
   return process.env.BT_VIEWS_SDK_PATH || path.join(__dirname, "views-sdk.ts");
 }
@@ -357,7 +345,6 @@ async function bundleForDiscovery(
   esbuild: EsbuildModule,
   sourceFile: string,
   outputFile: string,
-  tsconfig: string | undefined,
 ): Promise {
   return await esbuild.build({
     entryPoints: [sourceFile],
@@ -366,7 +353,6 @@ async function bundleForDiscovery(
     platform: "node",
     target: "node18",
     format: "cjs",
-    tsconfig,
     write: true,
     metafile: true,
     plugins: [braintrustViewPlugin()],
@@ -377,7 +363,6 @@ async function collectView(
   esbuild: EsbuildModule,
   sourceFile: string,
   tempDir: string,
-  tsconfig: string | undefined,
 ): Promise<{ view: CustomViewDefinition; dependencies: string[] }> {
   installDiscoveryReactStub();
   const outputFile = path.join(
@@ -388,7 +373,6 @@ async function collectView(
     esbuild,
     sourceFile,
     outputFile,
-    tsconfig,
   );
   const loaded = await import(
     `${pathToFileURL(outputFile).href}?bt_view_nonce=${Date.now()}`
@@ -413,7 +397,6 @@ async function bundleBrowserCode(
   sourceFile: string,
   view: CustomViewDefinition,
   tempDir: string,
-  tsconfig: string | undefined,
 ): Promise<{ code: string; dependencies: string[] }> {
   const entryFile = path.join(
     tempDir,
@@ -433,7 +416,6 @@ async function bundleBrowserCode(
     target: "es2019",
     format: "iife",
     globalName: "__BraintrustCustomView",
-    tsconfig,
     write: true,
     metafile: true,
     treeShaking: true,
@@ -483,7 +465,6 @@ function dependencyFiles(
 async function buildManifest(files: string[]): Promise {
   const sourceFiles = files.map((file) => path.resolve(file));
   const esbuild = await loadEsbuild(sourceFiles);
-  const tsconfig = loadTsconfigPath();
   const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "bt-views-"));
 
   try {
@@ -499,13 +480,12 @@ async function buildManifest(files: string[]): Promise {
       if (!fs.existsSync(sourceFile)) {
         throw new Error(`custom view file not found: ${sourceFile}`);
       }
-      const collected = await collectView(esbuild, sourceFile, tempDir, tsconfig);
+      const collected = await collectView(esbuild, sourceFile, tempDir);
       const bundled = await bundleBrowserCode(
         esbuild,
         sourceFile,
         collected.view,
         tempDir,
-        tsconfig,
       );
       const dependencies = [
         ...new Set([...collected.dependencies, ...bundled.dependencies]),
diff --git a/src/views.rs b/src/views.rs
index beadce5..041a809 100644
--- a/src/views.rs
+++ b/src/views.rs
@@ -60,9 +60,6 @@ enum ViewsCommands {
     Trace(TraceViewsArgs),
     /// Work with dataset custom views
     Dataset(DatasetViewsArgs),
-    /// Preview one custom view locally
-    #[command(hide = true)]
-    Preview(ViewsPreviewArgs),
 }
 
 #[derive(Debug, Clone, Args)]
@@ -89,14 +86,6 @@ struct ViewsPushArgs {
     )]
     if_exists: IfExistsMode,
 
-    /// Override JS runner binary (e.g. tsx, vite-node, deno).
-    #[arg(long, env = "BT_VIEWS_PUSH_RUNNER", value_name = "RUNNER")]
-    runner: Option,
-
-    /// Optional tsconfig path for JS runner and browser bundling.
-    #[arg(long, env = "BT_VIEWS_PUSH_TSCONFIG", value_name = "PATH")]
-    tsconfig: Option,
-
     /// Skip confirmation prompt.
     #[arg(
         long,
@@ -231,14 +220,6 @@ struct PreviewCommonArgs {
         default_value_t = false
     )]
     no_open: bool,
-
-    /// Override JS runner binary (e.g. tsx, vite-node, deno).
-    #[arg(long, env = "BT_VIEWS_PREVIEW_RUNNER", value_name = "RUNNER")]
-    runner: Option,
-
-    /// Optional tsconfig path for JS runner and browser bundling.
-    #[arg(long, env = "BT_VIEWS_PREVIEW_TSCONFIG", value_name = "PATH")]
-    tsconfig: Option,
 }
 
 #[derive(Debug, Clone, Args)]
@@ -248,15 +229,6 @@ struct TraceViewPreviewArgs {
 
     #[command(flatten)]
     target: TracePreviewTargetArgs,
-
-    /// Print BTQL queries used for preview data.
-    #[arg(
-        long,
-        env = "BT_VIEWS_PREVIEW_PRINT_QUERIES",
-        value_parser = BoolishValueParser::new(),
-        default_value_t = false
-    )]
-    print_queries: bool,
 }
 
 #[derive(Debug, Clone, Args)]
@@ -265,10 +237,6 @@ struct TracePreviewTargetArgs {
     #[arg(long, env = "BT_VIEWS_PREVIEW_URL")]
     url: Option,
 
-    /// Object reference, format: project_logs:.
-    #[arg(long, env = "BT_VIEWS_PREVIEW_OBJECT_REF")]
-    object_ref: Option,
-
     /// Project ID to query for trace preview data.
     #[arg(long, env = "BT_VIEWS_PREVIEW_PROJECT_ID")]
     project_id: Option,
@@ -310,27 +278,6 @@ struct DatasetPreviewTargetArgs {
     row_index: Option,
 }
 
-#[derive(Debug, Clone, Args)]
-struct ViewsPreviewArgs {
-    #[command(flatten)]
-    common: PreviewCommonArgs,
-
-    #[command(flatten)]
-    trace: TracePreviewTargetArgs,
-
-    #[command(flatten)]
-    dataset: DatasetPreviewTargetArgs,
-
-    /// Print BTQL queries used for preview data.
-    #[arg(
-        long,
-        env = "BT_VIEWS_PREVIEW_PRINT_QUERIES",
-        value_parser = BoolishValueParser::new(),
-        default_value_t = false
-    )]
-    print_queries: bool,
-}
-
 #[derive(Debug, Deserialize)]
 struct ViewsManifest {
     runtime_context: ViewsRuntimeContext,
@@ -437,8 +384,6 @@ struct PreviewSource {
     path: PathBuf,
     view: Option,
     view_type: Option,
-    runner: Option,
-    tsconfig: Option,
 }
 
 #[derive(Debug, Deserialize)]
@@ -463,7 +408,6 @@ pub async fn run(base: BaseArgs, args: ViewsArgs) -> Result<()> {
                 preview_dataset(base, preview_args).await
             }
         },
-        ViewsCommands::Preview(preview_args) => preview(base, preview_args).await,
     }
 }
 
@@ -929,7 +873,7 @@ async fn push(base: BaseArgs, args: ViewsPushArgs) -> Result<()> {
         bail!("no custom view files found; expected files matching {VIEW_FILE_PATTERN_HELP}");
     }
 
-    let manifest = run_views_runner(args.runner.as_deref(), args.tsconfig.as_deref(), &files)?;
+    let manifest = run_views_runner(&files)?;
     validate_manifest_runtime(&manifest)?;
     let prepared = prepare_views(&auth_ctx.client, &default_project, &manifest).await?;
     if prepared.is_empty() {
@@ -987,51 +931,6 @@ async fn push(base: BaseArgs, args: ViewsPushArgs) -> Result<()> {
     Ok(())
 }
 
-async fn preview(base: BaseArgs, args: ViewsPreviewArgs) -> Result<()> {
-    let context =
-        resolve_preview_context(&base, &args.common, None, args.trace.url.as_deref()).await?;
-    match context.entry.view_type {
-        ViewType::Trace => {
-            let trace_data = build_trace_preview_data(
-                &context.client,
-                context.project.as_ref(),
-                &args.trace,
-                args.print_queries,
-            )
-            .await?;
-            serve_preview(
-                base,
-                &args.common,
-                context.client,
-                Some(trace_data.project_id),
-                context.entry,
-                context.dependency_paths,
-                trace_data.data,
-            )
-            .await
-        }
-        ViewType::Dataset => {
-            let project = context
-                .project
-                .as_ref()
-                .ok_or_else(|| anyhow!("dataset preview requires a project"))?;
-            let preview_data =
-                build_dataset_preview_data(&context.client, project, &context.entry, &args.dataset)
-                    .await?;
-            serve_preview(
-                base,
-                &args.common,
-                context.client,
-                None,
-                context.entry,
-                context.dependency_paths,
-                preview_data,
-            )
-            .await
-        }
-    }
-}
-
 async fn preview_trace(base: BaseArgs, args: TraceViewPreviewArgs) -> Result<()> {
     let context = resolve_preview_context(
         &base,
@@ -1040,13 +939,8 @@ async fn preview_trace(base: BaseArgs, args: TraceViewPreviewArgs) -> Result<()>
         args.target.url.as_deref(),
     )
     .await?;
-    let trace_data = build_trace_preview_data(
-        &context.client,
-        context.project.as_ref(),
-        &args.target,
-        args.print_queries,
-    )
-    .await?;
+    let trace_data =
+        build_trace_preview_data(&context.client, context.project.as_ref(), &args.target).await?;
     serve_preview(
         base,
         &args.common,
@@ -1099,7 +993,7 @@ async fn resolve_preview_context(
         bail!("custom view preview path must match {VIEW_FILE_PATTERN_HELP}");
     }
     let files = vec![args.path.clone()];
-    let manifest = run_views_runner(args.runner.as_deref(), args.tsconfig.as_deref(), &files)?;
+    let manifest = run_views_runner(&files)?;
     validate_manifest_runtime(&manifest)?;
     let entry = select_preview_entry(&manifest, args.view.as_deref(), view_type)?.clone();
     let dependency_paths = preview_dependency_paths(&manifest);
@@ -1150,8 +1044,6 @@ async fn serve_preview(
             path: args.path.clone(),
             view: args.view.clone(),
             view_type: Some(entry.view_type),
-            runner: args.runner.clone(),
-            tsconfig: args.tsconfig.clone(),
         },
         title: entry.name.clone(),
         dependency_paths: Mutex::new(dependency_paths),
@@ -1255,7 +1147,7 @@ async fn preview_span_fields(
 
     for span_id in &body.span_ids {
         let row_id = resolve_preview_row_id(&spans, span_id).unwrap_or_else(|| span_id.clone());
-        match fetch_full_span_row(&state.client, project_id, &row_id, false).await {
+        match fetch_full_span_row(&state.client, project_id, &row_id).await {
             Ok(Some(row)) => {
                 response.insert(
                     span_id.clone(),
@@ -1278,7 +1170,7 @@ async fn preview_span_fields(
 
 fn load_preview_page(source: &PreviewSource) -> Result {
     let files = vec![source.path.clone()];
-    let manifest = run_views_runner(source.runner.as_deref(), source.tsconfig.as_deref(), &files)?;
+    let manifest = run_views_runner(&files)?;
     validate_manifest_runtime(&manifest)?;
     let entry = select_preview_entry(&manifest, source.view.as_deref(), source.view_type)?;
     let dependency_paths = preview_dependency_paths(&manifest);
@@ -1308,9 +1200,6 @@ fn preview_source_version(source: &PreviewSource, dependency_paths: &[PathBuf])
             push_preview_file_version(&mut parts, dependency);
         }
     }
-    if let Some(tsconfig) = &source.tsconfig {
-        push_preview_file_version(&mut parts, tsconfig);
-    }
     parts.join("|")
 }
 
@@ -1596,11 +1485,7 @@ fn is_view_file(path: &Path) -> bool {
     .any(|suffix| name.ends_with(suffix))
 }
 
-fn run_views_runner(
-    runner: Option<&str>,
-    tsconfig: Option<&Path>,
-    files: &[PathBuf],
-) -> Result {
+fn run_views_runner(files: &[PathBuf]) -> Result {
     let sdk_script = js_runner::materialize_runner_script_in_cwd(
         "views-runners",
         VIEWS_JS_SDK_FILE,
@@ -1613,12 +1498,8 @@ fn run_views_runner(
         VIEWS_JS_RUNNER_SOURCE,
     )
     .context("failed to materialize custom views runner")?;
-    let mut command = js_runner::build_js_runner_command(runner, &runner_script, files);
+    let mut command = js_runner::build_js_runner_command(None, &runner_script, files);
     command.env("BT_VIEWS_SDK_PATH", &sdk_script);
-    if let Some(tsconfig) = tsconfig {
-        command.env("TS_NODE_PROJECT", tsconfig);
-        command.env("TSX_TSCONFIG_PATH", tsconfig);
-    }
 
     let output = command.output().with_context(|| {
         format!(
@@ -2018,7 +1899,6 @@ async fn build_trace_preview_data(
     client: &ApiClient,
     project: Option<&Project>,
     args: &TracePreviewTargetArgs,
-    print_queries: bool,
 ) -> Result {
     let target = resolve_trace_preview_target(client, project, args).await?;
     let rows = fetch_trace_rows(
@@ -2026,7 +1906,6 @@ async fn build_trace_preview_data(
         &target.project_id,
         &target.root_span_id,
         DEFAULT_TRACE_PREVIEW_LIMIT,
-        print_queries,
     )
     .await?;
     if rows.is_empty() {
@@ -2093,22 +1972,13 @@ async fn resolve_trace_preview_target(
         });
     }
 
-    let project_id = if let Some(object_ref) = args.object_ref.as_deref() {
-        let (object_type, object_name) = object_ref.split_once(':').ok_or_else(|| {
-            anyhow!("invalid --object-ref '{object_ref}', expected project_logs:")
+    let project_id = args
+        .project_id
+        .clone()
+        .or_else(|| default_project.map(|project| project.id.clone()))
+        .ok_or_else(|| {
+            anyhow!("trace preview requires --project-id or --project when --trace-id is used")
         })?;
-        if object_type != "project_logs" {
-            bail!("bt views preview currently supports project_logs object refs");
-        }
-        object_name.to_string()
-    } else {
-        args.project_id
-            .clone()
-            .or_else(|| default_project.map(|project| project.id.clone()))
-            .ok_or_else(|| {
-                anyhow!("trace preview requires --project-id or --project when --trace-id is used")
-            })?
-    };
     let root_span_id = args
         .trace_id
         .clone()
@@ -2183,7 +2053,6 @@ async fn fetch_trace_rows(
     project_id: &str,
     root_span_id: &str,
     limit: usize,
-    print_queries: bool,
 ) -> Result>> {
     let query = format!(
         "select: * | from: project_logs({}) spans | filter: root_span_id = {} | preview_length: 125 | sort: _pagination_key ASC | limit: {}",
@@ -2191,7 +2060,6 @@ async fn fetch_trace_rows(
         sql_quote(root_span_id),
         limit,
     );
-    maybe_print_query(print_queries, "custom-view-preview-trace", &query);
     execute_query(client, &query)
         .await
         .with_context(|| format!("BTQL query failed: {query}"))
@@ -2202,14 +2070,12 @@ async fn fetch_full_span_row(
     client: &ApiClient,
     project_id: &str,
     row_id: &str,
-    print_queries: bool,
 ) -> Result>> {
     let query = format!(
         "select: * | from: project_logs({}) spans | filter: id = {} | preview_length: -1 | limit: 1",
         sql_quote(project_id),
         sql_quote(row_id),
     );
-    maybe_print_query(print_queries, "custom-view-preview-span-fields", &query);
     execute_query(client, &query)
         .await
         .with_context(|| format!("BTQL query failed: {query}"))
@@ -2231,12 +2097,6 @@ async fn execute_query(client: &ApiClient, query: &str) -> Result
     client.post_with_headers("/btql", &body, &headers).await
 }
 
-fn maybe_print_query(enabled: bool, label: &str, query: &str) {
-    if enabled {
-        eprintln!("bt views [{label}] BTQL:\n{query}\n");
-    }
-}
-
 fn sql_quote(value: &str) -> String {
     format!("'{}'", value.replace('\'', "''"))
 }
@@ -2463,8 +2323,6 @@ mod tests {
             path: path.clone(),
             view: None,
             view_type: Some(ViewType::Trace),
-            runner: None,
-            tsconfig: None,
         };
 
         let before = preview_source_version(&source, &[]);
@@ -2490,8 +2348,6 @@ mod tests {
             path,
             view: None,
             view_type: Some(ViewType::Trace),
-            runner: None,
-            tsconfig: None,
         };
 
         let dependency_paths = vec![dependency.clone()];
@@ -2542,7 +2398,6 @@ mod tests {
                 url: Some(format!(
                     "https://www.braintrust.dev/app/test-org/p/{project_id}/logs?r=root-span&s=selected-span"
                 )),
-                object_ref: None,
                 project_id: None,
                 trace_id: None,
                 span_id: None,
@@ -2565,7 +2420,6 @@ mod tests {
             Some(&project),
             &TracePreviewTargetArgs {
                 url: Some("https://www.braintrust.dev/app/test-org/logs?r=root-span".to_string()),
-                object_ref: None,
                 project_id: None,
                 trace_id: None,
                 span_id: Some("selected-span".to_string()),
@@ -2587,7 +2441,6 @@ mod tests {
             None,
             &TracePreviewTargetArgs {
                 url: Some("https://www.braintrust.dev/app/test-org/logs?r=root-span".to_string()),
-                object_ref: None,
                 project_id: None,
                 trace_id: None,
                 span_id: None,
diff --git a/tests/cli.rs b/tests/cli.rs
index e96ae3e..6fd3167 100644
--- a/tests/cli.rs
+++ b/tests/cli.rs
@@ -144,8 +144,7 @@ fn views_push_help_lists_custom_view_flags() {
         .args(["views", "push", "--help"])
         .assert()
         .success()
-        .stdout(predicate::str::contains("--if-exists"))
-        .stdout(predicate::str::contains("--tsconfig"));
+        .stdout(predicate::str::contains("--if-exists"));
 }
 
 #[test]
@@ -172,6 +171,14 @@ fn views_dataset_preview_help_lists_dataset_selectors() {
         .stdout(predicate::str::contains("--row-index"));
 }
 
+#[test]
+fn views_preview_hidden_aggregate_command_is_removed() {
+    bt_command()
+        .args(["views", "preview", "--help"])
+        .assert()
+        .failure();
+}
+
 #[test]
 fn views_trace_bootstrap_creates_starter_file() {
     let dir = tempfile::tempdir().expect("tempdir");

From bd3dca06f33e50e7d57baf7fbb51eb6fcd1bb7d1 Mon Sep 17 00:00:00 2001
From: Luca Forstner 
Date: Wed, 8 Jul 2026 19:01:37 +0200
Subject: [PATCH 5/6] batteries

---
 .github/workflows/release.yml |   24 +
 .github/workflows/tests.yml   |   18 +
 Cargo.lock                    | 3226 ++++++++++++++++++++++++++-------
 Cargo.toml                    |   19 +-
 build.rs                      |   46 +
 package.json                  |    3 +
 pnpm-lock.yaml                |   81 +-
 scripts/views-preview.html    |  210 +++
 scripts/views-runner.mjs      |  189 ++
 scripts/views-runner.ts       |  530 ------
 src/views.rs                  | 1550 ++++++++++++----
 11 files changed, 4312 insertions(+), 1584 deletions(-)
 create mode 100644 scripts/views-preview.html
 create mode 100644 scripts/views-runner.mjs
 delete mode 100644 scripts/views-runner.ts

diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index f480228..d222690 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -179,6 +179,18 @@ jobs:
             echo "$HOME/.cargo/bin" >> $GITHUB_PATH
           fi
 
+      - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
+        with:
+          node-version: "22.15.0"
+
+      - name: Enable pnpm
+        run: |
+          corepack enable
+          corepack prepare pnpm@10.28.2 --activate
+
+      - name: Install JS dependencies
+        run: pnpm install --ignore-scripts
+
       - name: Install sha256sum (macOS ARM64)
         if: ${{ runner.os == 'macOS' && runner.arch == 'ARM64' }}
         shell: bash
@@ -291,6 +303,18 @@ jobs:
 
       - run: chmod +x ~/.cargo/bin/dist
 
+      - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
+        with:
+          node-version: "22.15.0"
+
+      - name: Enable pnpm
+        run: |
+          corepack enable
+          corepack prepare pnpm@10.28.2 --activate
+
+      - name: Install JS dependencies
+        run: pnpm install --ignore-scripts
+
       - name: Fetch local artifacts
         uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
         with:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index fa77085..6b2d321 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -29,6 +29,15 @@ jobs:
         uses: dtolnay/rust-toolchain@efa25f7f19611383d5b0ccf2d1c8914531636bf9 # master
         with:
           toolchain: 1.92.0
+      - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
+        with:
+          node-version: "22.15.0"
+      - name: Enable pnpm
+        run: |
+          corepack enable
+          corepack prepare pnpm@10.28.2 --activate
+      - name: Install JS dependencies
+        run: pnpm install --ignore-scripts
       - name: Run auth unit tests
         run: cargo test auth::tests -- --nocapture
 
@@ -106,5 +115,14 @@ jobs:
         with:
           python-version: ${{ matrix.python-version }}
       - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
+      - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
+        with:
+          node-version: "22.15.0"
+      - name: Enable pnpm
+        run: |
+          corepack enable
+          corepack prepare pnpm@10.28.2 --activate
+      - name: Install JS dependencies
+        run: pnpm install --ignore-scripts
       - name: Run eval fixtures
         run: cargo test --test eval_fixtures
diff --git a/Cargo.lock b/Cargo.lock
index ea38d50..784592c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -8,7 +8,7 @@ version = "0.5.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a"
 dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
  "bytes",
  "futures-core",
  "futures-sink",
@@ -30,14 +30,14 @@ dependencies = [
  "actix-service",
  "actix-utils",
  "base64 0.22.1",
- "bitflags",
+ "bitflags 2.13.0",
  "brotli",
  "bytes",
  "bytestring",
  "derive_more",
  "encoding_rs",
  "flate2",
- "foldhash",
+ "foldhash 0.1.5",
  "futures-core",
  "h2",
  "http 0.2.12",
@@ -65,7 +65,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb"
 dependencies = [
  "quote",
- "syn",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -151,7 +151,7 @@ dependencies = [
  "cookie",
  "derive_more",
  "encoding_rs",
- "foldhash",
+ "foldhash 0.1.5",
  "futures-core",
  "futures-util",
  "impl-more",
@@ -182,7 +182,7 @@ dependencies = [
  "actix-router",
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -191,6 +191,18 @@ version = "2.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
 
+[[package]]
+name = "ahash"
+version = "0.8.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "version_check",
+ "zerocopy",
+]
+
 [[package]]
 name = "aho-corasick"
 version = "1.1.4"
@@ -286,6 +298,24 @@ version = "1.0.102"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
 
+[[package]]
+name = "approx"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "ar_archive_writer"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4087686b4b0a3427190bae57a1d9a478dbb2d40c5dc1bd6e2b6d797913bdd348"
+dependencies = [
+ "object",
+]
+
 [[package]]
 name = "arc-swap"
 version = "1.9.1"
@@ -295,6 +325,12 @@ dependencies = [
  "rustversion",
 ]
 
+[[package]]
+name = "ascii"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
+
 [[package]]
 name = "assert-json-diff"
 version = "2.0.2"
@@ -320,6 +356,17 @@ dependencies = [
  "wait-timeout",
 ]
 
+[[package]]
+name = "ast_node"
+version = "5.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2eb025ef00a6da925cf40870b9c8d008526b6004ece399cb0974209720f0b194"
+dependencies = [
+ "quote",
+ "swc_macros_common",
+ "syn 2.0.117",
+]
+
 [[package]]
 name = "async-compression"
 version = "0.4.41"
@@ -340,7 +387,16 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "atomic"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340"
+dependencies = [
+ "bytemuck",
 ]
 
 [[package]]
@@ -349,6 +405,17 @@ version = "1.1.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
 
+[[package]]
+name = "auto_impl"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
 [[package]]
 name = "autocfg"
 version = "1.5.0"
@@ -387,6 +454,25 @@ version = "0.22.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
 
+[[package]]
+name = "base64-simd"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195"
+dependencies = [
+ "outref",
+ "vsimd",
+]
+
+[[package]]
+name = "better_scoped_tls"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd228125315b132eed175bf47619ac79b945b26e56b848ba203ae4ea8603609"
+dependencies = [
+ "scoped-tls",
+]
+
 [[package]]
 name = "big_serde_json"
 version = "0.1.0"
@@ -396,11 +482,32 @@ dependencies = [
  "ts-rs",
 ]
 
+[[package]]
+name = "bit-set"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1"
+dependencies = [
+ "bit-vec",
+]
+
+[[package]]
+name = "bit-vec"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
+
 [[package]]
 name = "bitflags"
-version = "2.11.0"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.13.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
+checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
 
 [[package]]
 name = "block-buffer"
@@ -433,7 +540,7 @@ dependencies = [
  "proc-macro2",
  "quote",
  "rustversion",
- "syn",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -522,6 +629,12 @@ dependencies = [
  "lingua",
  "oauth2",
  "open",
+ "oxc_allocator",
+ "oxc_codegen",
+ "oxc_parser",
+ "oxc_semantic",
+ "oxc_span",
+ "oxc_transformer",
  "pathdiff",
  "predicates",
  "ratatui",
@@ -532,6 +645,17 @@ dependencies = [
  "serde_path_to_error",
  "sha2",
  "strip-ansi-escapes",
+ "swc_atoms",
+ "swc_bundler",
+ "swc_common 21.0.2",
+ "swc_ecma_ast 23.0.1",
+ "swc_ecma_codegen",
+ "swc_ecma_loader",
+ "swc_ecma_parser",
+ "swc_ecma_transforms_base",
+ "swc_ecma_transforms_react",
+ "swc_ecma_transforms_typescript",
+ "swc_ecma_visit 22.0.0",
  "tempfile",
  "tokio",
  "toml",
@@ -543,9 +667,24 @@ dependencies = [
 
 [[package]]
 name = "bumpalo"
-version = "3.20.2"
+version = "3.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
+dependencies = [
+ "allocator-api2",
+]
+
+[[package]]
+name = "by_address"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06"
+
+[[package]]
+name = "bytemuck"
+version = "1.25.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
+checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
 
 [[package]]
 name = "bytes"
@@ -554,19 +693,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
 
 [[package]]
-name = "bytestring"
-version = "1.5.0"
+name = "bytes-str"
+version = "0.2.8"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "113b4343b5f6617e7ad401ced8de3cc8b012e73a594347c307b90db3e9271289"
+checksum = "577d2bf5650f8554d5a372af5ac93535110a0fc75b3e702bb853369febf227c2"
 dependencies = [
  "bytes",
+ "serde",
 ]
 
 [[package]]
-name = "cassowary"
-version = "0.3.0"
+name = "bytestring"
+version = "1.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
+checksum = "113b4343b5f6617e7ad401ced8de3cc8b012e73a594347c307b90db3e9271289"
+dependencies = [
+ "bytes",
+]
 
 [[package]]
 name = "castaway"
@@ -646,7 +789,7 @@ dependencies = [
  "heck",
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -655,6 +798,15 @@ version = "1.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
 
+[[package]]
+name = "cobs"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
+dependencies = [
+ "thiserror 2.0.18",
+]
+
 [[package]]
 name = "colorchoice"
 version = "1.0.4"
@@ -669,14 +821,27 @@ checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47"
 dependencies = [
  "crossterm 0.29.0",
  "unicode-segmentation",
- "unicode-width 0.2.0",
+ "unicode-width 0.2.2",
 ]
 
 [[package]]
 name = "compact_str"
-version = "0.8.1"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f"
+dependencies = [
+ "castaway",
+ "cfg-if",
+ "itoa",
+ "ryu",
+ "static_assertions",
+]
+
+[[package]]
+name = "compact_str"
+version = "0.9.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32"
+checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab"
 dependencies = [
  "castaway",
  "cfg-if",
@@ -712,7 +877,7 @@ dependencies = [
  "encode_unicode",
  "libc",
  "once_cell",
- "unicode-width 0.2.0",
+ "unicode-width 0.2.2",
  "windows-sys 0.59.0",
 ]
 
@@ -752,6 +917,12 @@ version = "0.8.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
 
+[[package]]
+name = "cow-utils"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "417bef24afe1460300965a25ff4a24b8b45ad011948302ec221e8a0a81eb2c79"
+
 [[package]]
 name = "cpufeatures"
 version = "0.2.17"
@@ -761,6 +932,21 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "crc"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49fc9a695bca7f35f5f4c15cddc84415f66a74ea78eef08e90c5024f2b540e23"
+dependencies = [
+ "crc-catalog",
+]
+
+[[package]]
+name = "crc-catalog"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccaeedb56da03b09f598226e25e80088cb4cd25f316e6e4df7d695f0feeb1403"
+
 [[package]]
 name = "crc32fast"
 version = "1.5.0"
@@ -770,6 +956,12 @@ dependencies = [
  "cfg-if",
 ]
 
+[[package]]
+name = "critical-section"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
+
 [[package]]
 name = "crossbeam"
 version = "0.8.4"
@@ -832,7 +1024,7 @@ version = "0.28.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
 dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
  "crossterm_winapi",
  "mio",
  "parking_lot",
@@ -848,11 +1040,15 @@ version = "0.29.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
 dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
  "crossterm_winapi",
+ "derive_more",
  "document-features",
+ "mio",
  "parking_lot",
  "rustix 1.1.4",
+ "signal-hook",
+ "signal-hook-mio",
  "winapi",
 ]
 
@@ -875,6 +1071,16 @@ dependencies = [
  "typenum",
 ]
 
+[[package]]
+name = "csscolorparser"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf"
+dependencies = [
+ "lab",
+ "phf 0.11.3",
+]
+
 [[package]]
 name = "darling"
 version = "0.21.3"
@@ -906,7 +1112,7 @@ dependencies = [
  "proc-macro2",
  "quote",
  "strsim",
- "syn",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -919,7 +1125,7 @@ dependencies = [
  "proc-macro2",
  "quote",
  "strsim",
- "syn",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -930,7 +1136,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
 dependencies = [
  "darling_core 0.21.3",
  "quote",
- "syn",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -941,9 +1147,29 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
 dependencies = [
  "darling_core 0.23.0",
  "quote",
- "syn",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "dashmap"
+version = "6.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
+dependencies = [
+ "cfg-if",
+ "crossbeam-utils",
+ "hashbrown 0.14.5",
+ "lock_api",
+ "once_cell",
+ "parking_lot_core",
 ]
 
+[[package]]
+name = "deltae"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4"
+
 [[package]]
 name = "deranged"
 version = "0.5.8"
@@ -973,7 +1199,7 @@ dependencies = [
  "proc-macro2",
  "quote",
  "rustc_version",
- "syn",
+ "syn 2.0.117",
  "unicode-xid",
 ]
 
@@ -1036,7 +1262,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -1054,6 +1280,18 @@ version = "0.15.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
 
+[[package]]
+name = "dragonbox_ecma"
+version = "0.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d742b56656e8b14d63e7ea9806597b1849ae25412584c8adf78c0f67bd985e66"
+
+[[package]]
+name = "dragonbox_ecma"
+version = "0.1.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd8e701084c37e7ef62d3f9e453b618130cbc0ef3573847785952a3ac3f746bf"
+
 [[package]]
 name = "dyn-clone"
 version = "1.0.20"
@@ -1066,6 +1304,18 @@ version = "1.15.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
 
+[[package]]
+name = "embedded-io"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced"
+
+[[package]]
+name = "embedded-io"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
+
 [[package]]
 name = "encode_unicode"
 version = "1.0.0"
@@ -1094,21 +1344,69 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
 dependencies = [
  "libc",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "euclid"
+version = "0.22.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "fancy-regex"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2"
+dependencies = [
+ "bit-set",
+ "regex",
 ]
 
+[[package]]
+name = "fast-srgb8"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1"
+
 [[package]]
 name = "fastrand"
 version = "2.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
 
+[[package]]
+name = "filedescriptor"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d"
+dependencies = [
+ "libc",
+ "thiserror 1.0.69",
+ "winapi",
+]
+
 [[package]]
 name = "find-msvc-tools"
 version = "0.1.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
 
+[[package]]
+name = "finl_unicode"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5"
+
+[[package]]
+name = "fixedbitset"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
+
 [[package]]
 name = "fixedbitset"
 version = "0.5.7"
@@ -1146,6 +1444,12 @@ version = "0.1.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
 
+[[package]]
+name = "foldhash"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
+
 [[package]]
 name = "form_urlencoded"
 version = "1.2.2"
@@ -1155,6 +1459,16 @@ dependencies = [
  "percent-encoding",
 ]
 
+[[package]]
+name = "from_variant"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5ff35a391aef949120a0340d690269b3d9f63460a6106e99bd07b961f345ea9"
+dependencies = [
+ "swc_macros_common",
+ "syn 2.0.117",
+]
+
 [[package]]
 name = "futures"
 version = "0.3.32"
@@ -1211,7 +1525,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -1333,6 +1647,16 @@ version = "0.12.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
 
+[[package]]
+name = "hashbrown"
+version = "0.14.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
+dependencies = [
+ "ahash",
+ "allocator-api2",
+]
+
 [[package]]
 name = "hashbrown"
 version = "0.15.5"
@@ -1340,8 +1664,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
 dependencies = [
  "allocator-api2",
- "equivalent",
- "foldhash",
+ "foldhash 0.1.5",
 ]
 
 [[package]]
@@ -1349,6 +1672,22 @@ name = "hashbrown"
 version = "0.16.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash 0.2.0",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash 0.2.0",
+]
 
 [[package]]
 name = "heck"
@@ -1356,12 +1695,32 @@ version = "0.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
 
+[[package]]
+name = "hermit-abi"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
+
 [[package]]
 name = "hex"
 version = "0.4.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
 
+[[package]]
+name = "hstr"
+version = "3.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83bb87e4b300d73412f6dcc7022ee7741452b51b155c2b06e5994d0770c2dbe2"
+dependencies = [
+ "hashbrown 0.14.5",
+ "new_debug_unreachable",
+ "once_cell",
+ "rustc-hash",
+ "serde",
+ "triomphe",
+]
+
 [[package]]
 name = "http"
 version = "0.2.12"
@@ -1474,7 +1833,7 @@ dependencies = [
  "libc",
  "percent-encoding",
  "pin-project-lite",
- "socket2 0.5.10",
+ "socket2 0.6.2",
  "tokio",
  "tower-service",
  "tracing",
@@ -1656,7 +2015,7 @@ dependencies = [
  "console",
  "number_prefix",
  "portable-atomic",
- "unicode-width 0.2.0",
+ "unicode-width 0.2.2",
  "web-time",
 ]
 
@@ -1679,7 +2038,7 @@ dependencies = [
  "indoc",
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -1717,13 +2076,25 @@ dependencies = [
 ]
 
 [[package]]
-name = "is-wsl"
-version = "0.4.0"
+name = "is-macro"
+version = "0.3.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
+checksum = "1d57a3e447e24c22647738e4607f1df1e0ec6f72e16182c4cd199f647cdfb0e4"
 dependencies = [
- "is-docker",
- "once_cell",
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "is-wsl"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
+dependencies = [
+ "is-docker",
+ "once_cell",
 ]
 
 [[package]]
@@ -1776,12 +2147,44 @@ dependencies = [
  "wasm-bindgen",
 ]
 
+[[package]]
+name = "json-escape-simd"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a1f7d5786a4cb0f4e0f862b562a0e085b5bfa23a4f0dc05e7b823ed4e4d791f"
+dependencies = [
+ "anyhow",
+]
+
+[[package]]
+name = "kasuari"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899"
+dependencies = [
+ "hashbrown 0.16.1",
+ "portable-atomic",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "lab"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f"
+
 [[package]]
 name = "language-tags"
 version = "0.3.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388"
 
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+
 [[package]]
 name = "leb128fmt"
 version = "0.1.0"
@@ -1794,16 +2197,31 @@ version = "0.2.182"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
 
+[[package]]
+name = "libm"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
+
 [[package]]
 name = "libredox"
 version = "0.1.12"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616"
 dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
  "libc",
 ]
 
+[[package]]
+name = "line-clipping"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8"
+dependencies = [
+ "bitflags 2.13.0",
+]
+
 [[package]]
 name = "lingua"
 version = "0.1.0"
@@ -1892,11 +2310,11 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
 
 [[package]]
 name = "lru"
-version = "0.12.5"
+version = "0.18.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
+checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9"
 dependencies = [
- "hashbrown 0.15.5",
+ "hashbrown 0.17.1",
 ]
 
 [[package]]
@@ -1905,12 +2323,37 @@ version = "0.1.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
 
+[[package]]
+name = "mac_address"
+version = "1.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303"
+dependencies = [
+ "nix",
+ "winapi",
+]
+
 [[package]]
 name = "memchr"
 version = "2.8.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
 
+[[package]]
+name = "memmem"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15"
+
+[[package]]
+name = "memoffset"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
+dependencies = [
+ "autocfg",
+]
+
 [[package]]
 name = "mime"
 version = "0.3.17"
@@ -1927,6 +2370,12 @@ dependencies = [
  "unicase",
 ]
 
+[[package]]
+name = "minimal-lexical"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
 [[package]]
 name = "miniz_oxide"
 version = "0.8.9"
@@ -1955,18 +2404,93 @@ version = "0.10.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
 
+[[package]]
+name = "new_debug_unreachable"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
+
+[[package]]
+name = "nix"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
+dependencies = [
+ "bitflags 2.13.0",
+ "cfg-if",
+ "cfg_aliases",
+ "libc",
+ "memoffset",
+]
+
+[[package]]
+name = "nom"
+version = "7.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
+dependencies = [
+ "memchr",
+ "minimal-lexical",
+]
+
+[[package]]
+name = "nonmax"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "610a5acd306ec67f907abe5567859a3c693fb9886eb1f012ab8f2a47bef3db51"
+
 [[package]]
 name = "normalize-line-endings"
 version = "0.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be"
 
+[[package]]
+name = "normpath"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a9da8c9922c35a1033d76f7272dfc2e7ee20392083d75aeea6ced23c6266578"
+dependencies = [
+ "winapi",
+]
+
+[[package]]
+name = "num-bigint"
+version = "0.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
+dependencies = [
+ "num-integer",
+ "num-traits",
+ "serde",
+]
+
 [[package]]
 name = "num-conv"
 version = "0.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
 
+[[package]]
+name = "num-derive"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "num-integer"
+version = "0.1.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
+dependencies = [
+ "num-traits",
+]
+
 [[package]]
 name = "num-traits"
 version = "0.2.19"
@@ -1976,6 +2500,25 @@ dependencies = [
  "autocfg",
 ]
 
+[[package]]
+name = "num_cpus"
+version = "1.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
+dependencies = [
+ "hermit-abi",
+ "libc",
+]
+
+[[package]]
+name = "num_threads"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
+dependencies = [
+ "libc",
+]
+
 [[package]]
 name = "number_prefix"
 version = "0.4.0"
@@ -2001,11 +2544,20 @@ dependencies = [
  "url",
 ]
 
+[[package]]
+name = "object"
+version = "0.37.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
+dependencies = [
+ "memchr",
+]
+
 [[package]]
 name = "once_cell"
-version = "1.21.3"
+version = "1.21.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
 
 [[package]]
 name = "once_cell_polyfill"
@@ -2037,1003 +2589,2186 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
 
 [[package]]
-name = "parking_lot"
-version = "0.12.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
-dependencies = [
- "lock_api",
- "parking_lot_core",
-]
-
-[[package]]
-name = "parking_lot_core"
-version = "0.9.12"
+name = "ordered-float"
+version = "4.6.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951"
 dependencies = [
- "cfg-if",
- "libc",
- "redox_syscall",
- "smallvec",
- "windows-link",
+ "num-traits",
 ]
 
 [[package]]
-name = "paste"
-version = "1.0.15"
+name = "outref"
+version = "0.5.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
+checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"
 
 [[package]]
-name = "pathdiff"
-version = "0.2.3"
+name = "owo-colors"
+version = "4.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
+checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d"
 
 [[package]]
-name = "pbjson"
-version = "0.7.0"
+name = "oxc-browserslist"
+version = "2.3.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c7e6349fa080353f4a597daffd05cb81572a9c031a6d4fff7e504947496fcc68"
+checksum = "abb7a1163a5501f935f8722d839b576491b749c695e7a066aa0b8df988b806df"
 dependencies = [
- "base64 0.21.7",
+ "flate2",
+ "postcard",
+ "rustc-hash",
  "serde",
+ "serde_json 1.0.149",
+ "thiserror 2.0.18",
 ]
 
 [[package]]
-name = "pbjson-build"
-version = "0.7.0"
+name = "oxc-miette"
+version = "2.7.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6eea3058763d6e656105d1403cb04e0a41b7bbac6362d413e7c33be0c32279c9"
+checksum = "4356a61f2ed4c9b3610245215fbf48970eb277126919f87db9d0efa93a74245c"
 dependencies = [
- "heck",
- "itertools 0.13.0",
- "prost",
- "prost-types",
+ "cfg-if",
+ "owo-colors",
+ "oxc-miette-derive",
+ "textwrap",
+ "thiserror 2.0.18",
+ "unicode-segmentation",
+ "unicode-width 0.2.2",
 ]
 
 [[package]]
-name = "pbjson-types"
-version = "0.7.0"
+name = "oxc-miette-derive"
+version = "2.7.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e54e5e7bfb1652f95bc361d76f3c780d8e526b134b85417e774166ee941f0887"
+checksum = "b237422b014f8f8fff75bb9379e697d13f8d57551a22c88bebb39f073c1bf696"
 dependencies = [
- "bytes",
- "chrono",
- "pbjson",
- "pbjson-build",
- "prost",
- "prost-build",
- "serde",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
 ]
 
 [[package]]
-name = "percent-encoding"
-version = "2.3.2"
+name = "oxc_allocator"
+version = "0.81.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+checksum = "5c67f2b817263a72b6cc5b46e32467bc4077496f88315ce08c1796647fed84d4"
+dependencies = [
+ "allocator-api2",
+ "bumpalo",
+ "hashbrown 0.15.5",
+ "oxc_data_structures",
+ "rustc-hash",
+]
 
 [[package]]
-name = "petgraph"
-version = "0.7.1"
+name = "oxc_ast"
+version = "0.81.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772"
+checksum = "989b81258ce151e23adee6f1393f7c0fb7e10e3a6c6c001f71bbaff4081c72a4"
 dependencies = [
- "fixedbitset",
- "indexmap 2.13.0",
+ "bitflags 2.13.0",
+ "oxc_allocator",
+ "oxc_ast_macros",
+ "oxc_data_structures",
+ "oxc_estree",
+ "oxc_regular_expression",
+ "oxc_span",
+ "oxc_syntax",
 ]
 
 [[package]]
-name = "pin-project-lite"
-version = "0.2.16"
+name = "oxc_ast_macros"
+version = "0.81.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
+checksum = "ff7e8e42eea621e6fa7072a480fa04ae4dfe73f9a958822c066cc7f1eba57e82"
+dependencies = [
+ "phf 0.12.1",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
 
 [[package]]
-name = "pin-utils"
-version = "0.1.0"
+name = "oxc_ast_visit"
+version = "0.81.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
+checksum = "b0a968790ef8968de08d813a2fe1a15739930fb1c171fddeebf3545d802ea216"
+dependencies = [
+ "oxc_allocator",
+ "oxc_ast",
+ "oxc_span",
+ "oxc_syntax",
+]
 
 [[package]]
-name = "pkg-config"
-version = "0.3.32"
+name = "oxc_cfg"
+version = "0.81.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
+checksum = "5361ef66f38635d9671596d83abf44bcdbc3e39dbff303f3517828b86ad60e8f"
+dependencies = [
+ "bitflags 2.13.0",
+ "itertools 0.14.0",
+ "nonmax",
+ "oxc_index",
+ "oxc_syntax",
+ "petgraph 0.8.3",
+ "rustc-hash",
+]
 
 [[package]]
-name = "portable-atomic"
-version = "1.13.1"
+name = "oxc_codegen"
+version = "0.81.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
+checksum = "fde8fc6f7337632fce3e9e51c150f57fb0ef234524c6947f63e8009e1ea613cf"
+dependencies = [
+ "bitflags 2.13.0",
+ "cow-utils",
+ "dragonbox_ecma 0.0.5",
+ "nonmax",
+ "oxc_allocator",
+ "oxc_ast",
+ "oxc_data_structures",
+ "oxc_index",
+ "oxc_semantic",
+ "oxc_sourcemap",
+ "oxc_span",
+ "oxc_syntax",
+ "rustc-hash",
+]
 
 [[package]]
-name = "potential_utf"
-version = "0.1.4"
+name = "oxc_data_structures"
+version = "0.81.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77"
+checksum = "16c58a40be09dbb289150046a39da2fb130eb5b8ff1f013f2963e880e1af261c"
 dependencies = [
- "zerovec",
+ "ropey",
+ "rustversion",
 ]
 
 [[package]]
-name = "powerfmt"
-version = "0.2.0"
+name = "oxc_diagnostics"
+version = "0.81.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+checksum = "702bde62d95587518aec86247457830f189242f814fba8389beb6e8c1585c444"
+dependencies = [
+ "cow-utils",
+ "oxc-miette",
+ "percent-encoding",
+]
 
 [[package]]
-name = "ppv-lite86"
-version = "0.2.21"
+name = "oxc_ecmascript"
+version = "0.81.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+checksum = "cf4dab5abe97e790fe1b631a7eacb9a1cbecbe2dad5b984ef11fcb9115b376ab"
 dependencies = [
- "zerocopy",
+ "cow-utils",
+ "num-bigint",
+ "num-traits",
+ "oxc_allocator",
+ "oxc_ast",
+ "oxc_span",
+ "oxc_syntax",
 ]
 
 [[package]]
-name = "predicates"
-version = "3.1.4"
+name = "oxc_estree"
+version = "0.81.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe"
+checksum = "aa9a453daacd90f168554015dd49467038ab6894045805f861950c1d0a8e6a5e"
+
+[[package]]
+name = "oxc_index"
+version = "3.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "967ae797e1f284bd1385f2d8e8ab94293ad27f623c76839ecf66827521365f5b"
+
+[[package]]
+name = "oxc_parser"
+version = "0.81.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70253728183a5a92d1ba964d9952201e0269ebd07c4917c4553283d564225bab"
 dependencies = [
- "anstyle",
- "difflib",
- "float-cmp",
- "normalize-line-endings",
- "predicates-core",
- "regex",
+ "bitflags 2.13.0",
+ "cow-utils",
+ "memchr",
+ "num-bigint",
+ "num-traits",
+ "oxc_allocator",
+ "oxc_ast",
+ "oxc_data_structures",
+ "oxc_diagnostics",
+ "oxc_ecmascript",
+ "oxc_regular_expression",
+ "oxc_span",
+ "oxc_syntax",
+ "rustc-hash",
+ "seq-macro",
 ]
 
 [[package]]
-name = "predicates-core"
-version = "1.0.10"
+name = "oxc_regular_expression"
+version = "0.81.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144"
+checksum = "de5bb2ed6646bb7815126e0f36787c349e13561fed2c05f95b27a1f82dd066c4"
+dependencies = [
+ "bitflags 2.13.0",
+ "oxc_allocator",
+ "oxc_ast_macros",
+ "oxc_diagnostics",
+ "oxc_span",
+ "phf 0.12.1",
+ "rustc-hash",
+ "unicode-id-start",
+]
 
 [[package]]
-name = "predicates-tree"
-version = "1.0.13"
+name = "oxc_semantic"
+version = "0.81.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2"
+checksum = "06858ccc5976a5303ac903bef13d03b7f8860e837b7399e4899c05f5c729d30b"
 dependencies = [
- "predicates-core",
- "termtree",
+ "itertools 0.14.0",
+ "oxc_allocator",
+ "oxc_ast",
+ "oxc_ast_visit",
+ "oxc_cfg",
+ "oxc_data_structures",
+ "oxc_diagnostics",
+ "oxc_ecmascript",
+ "oxc_index",
+ "oxc_span",
+ "oxc_syntax",
+ "phf 0.12.1",
+ "rustc-hash",
+ "self_cell",
 ]
 
 [[package]]
-name = "prettyplease"
-version = "0.2.37"
+name = "oxc_sourcemap"
+version = "4.2.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
+checksum = "05ff280f25901ac054ad8552395ecd6a455e4b9352a94debbc34c433760586ef"
 dependencies = [
- "proc-macro2",
- "syn",
+ "base64-simd",
+ "json-escape-simd",
+ "rustc-hash",
+ "serde",
+ "serde_json 1.0.149",
 ]
 
 [[package]]
-name = "proc-macro2"
-version = "1.0.106"
+name = "oxc_span"
+version = "0.81.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+checksum = "2b315080fee7613168e0e08d3280efccdc12fea1efe5c0285e3a2d6265660f02"
 dependencies = [
- "unicode-ident",
+ "compact_str 0.9.1",
+ "oxc-miette",
+ "oxc_allocator",
+ "oxc_ast_macros",
+ "oxc_estree",
 ]
 
 [[package]]
-name = "prost"
-version = "0.13.5"
+name = "oxc_syntax"
+version = "0.81.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5"
+checksum = "72d9d80d47d2eee5c1e6dbcdf1841b42afc4f1cd1f63701fc346162616dac52f"
 dependencies = [
- "bytes",
- "prost-derive",
+ "bitflags 2.13.0",
+ "cow-utils",
+ "dragonbox_ecma 0.0.5",
+ "nonmax",
+ "oxc_allocator",
+ "oxc_ast_macros",
+ "oxc_data_structures",
+ "oxc_estree",
+ "oxc_index",
+ "oxc_span",
+ "phf 0.12.1",
+ "rustc-hash",
+ "unicode-id-start",
 ]
 
 [[package]]
-name = "prost-build"
-version = "0.13.5"
+name = "oxc_transformer"
+version = "0.81.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf"
+checksum = "89f15659109f115788d7750f31f970d500085e9544c250aa3b20ad23a3901c46"
 dependencies = [
- "heck",
- "itertools 0.14.0",
- "log",
- "multimap",
- "once_cell",
- "petgraph",
- "prettyplease",
- "prost",
- "prost-types",
- "regex",
- "syn",
- "tempfile",
+ "base64 0.22.1",
+ "compact_str 0.9.1",
+ "cow-utils",
+ "indexmap 2.13.0",
+ "itoa",
+ "memchr",
+ "oxc-browserslist",
+ "oxc_allocator",
+ "oxc_ast",
+ "oxc_ast_visit",
+ "oxc_data_structures",
+ "oxc_diagnostics",
+ "oxc_ecmascript",
+ "oxc_parser",
+ "oxc_regular_expression",
+ "oxc_semantic",
+ "oxc_span",
+ "oxc_syntax",
+ "oxc_traverse",
+ "rustc-hash",
+ "serde",
+ "serde_json 1.0.149",
+ "sha1",
 ]
 
 [[package]]
-name = "prost-derive"
-version = "0.13.5"
+name = "oxc_traverse"
+version = "0.81.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
+checksum = "adef9483d1b5132a82e05e7f6a4a0b72138bf3c6459d0a5ef40f1fe33e9f77d1"
 dependencies = [
- "anyhow",
- "itertools 0.14.0",
- "proc-macro2",
- "quote",
- "syn",
+ "itoa",
+ "oxc_allocator",
+ "oxc_ast",
+ "oxc_ast_visit",
+ "oxc_data_structures",
+ "oxc_ecmascript",
+ "oxc_semantic",
+ "oxc_span",
+ "oxc_syntax",
+ "rustc-hash",
 ]
 
 [[package]]
-name = "prost-types"
-version = "0.13.5"
+name = "palette"
+version = "0.7.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16"
+checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6"
 dependencies = [
- "prost",
+ "approx",
+ "fast-srgb8",
+ "libm",
+ "palette_derive",
 ]
 
 [[package]]
-name = "quinn"
-version = "0.11.9"
+name = "palette_derive"
+version = "0.7.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
+checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30"
 dependencies = [
- "bytes",
- "cfg_aliases",
- "pin-project-lite",
- "quinn-proto",
- "quinn-udp",
- "rustc-hash",
- "rustls",
- "socket2 0.5.10",
- "thiserror 2.0.18",
- "tokio",
- "tracing",
- "web-time",
+ "by_address",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
 ]
 
 [[package]]
-name = "quinn-proto"
-version = "0.11.13"
+name = "par-core"
+version = "2.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
+checksum = "e96cbd21255b7fb29a5d51ef38a779b517a91abd59e2756c039583f43ef4c90f"
 dependencies = [
- "bytes",
- "getrandom 0.3.4",
- "lru-slab",
- "rand 0.9.2",
- "ring",
- "rustc-hash",
- "rustls",
- "rustls-pki-types",
- "slab",
- "thiserror 2.0.18",
- "tinyvec",
- "tracing",
- "web-time",
+ "once_cell",
 ]
 
 [[package]]
-name = "quinn-udp"
-version = "0.5.14"
+name = "parking_lot"
+version = "0.12.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
 dependencies = [
- "cfg_aliases",
- "libc",
- "once_cell",
- "socket2 0.5.10",
- "tracing",
- "windows-sys 0.59.0",
+ "lock_api",
+ "parking_lot_core",
 ]
 
 [[package]]
-name = "quote"
-version = "1.0.44"
+name = "parking_lot_core"
+version = "0.9.12"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
 dependencies = [
- "proc-macro2",
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
 ]
 
 [[package]]
-name = "r-efi"
-version = "5.3.0"
+name = "path-clean"
+version = "1.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+checksum = "17359afc20d7ab31fdb42bb844c8b3bb1dabd7dcf7e68428492da7f16966fcef"
 
 [[package]]
-name = "rand"
-version = "0.8.5"
+name = "pathdiff"
+version = "0.2.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
+checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
+
+[[package]]
+name = "pbjson"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7e6349fa080353f4a597daffd05cb81572a9c031a6d4fff7e504947496fcc68"
 dependencies = [
- "libc",
- "rand_chacha 0.3.1",
- "rand_core 0.6.4",
+ "base64 0.21.7",
+ "serde",
 ]
 
 [[package]]
-name = "rand"
-version = "0.9.2"
+name = "pbjson-build"
+version = "0.7.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
+checksum = "6eea3058763d6e656105d1403cb04e0a41b7bbac6362d413e7c33be0c32279c9"
 dependencies = [
- "rand_chacha 0.9.0",
- "rand_core 0.9.5",
+ "heck",
+ "itertools 0.13.0",
+ "prost",
+ "prost-types",
 ]
 
 [[package]]
-name = "rand_chacha"
-version = "0.3.1"
+name = "pbjson-types"
+version = "0.7.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+checksum = "e54e5e7bfb1652f95bc361d76f3c780d8e526b134b85417e774166ee941f0887"
 dependencies = [
- "ppv-lite86",
- "rand_core 0.6.4",
+ "bytes",
+ "chrono",
+ "pbjson",
+ "pbjson-build",
+ "prost",
+ "prost-build",
+ "serde",
 ]
 
 [[package]]
-name = "rand_chacha"
-version = "0.9.0"
+name = "percent-encoding"
+version = "2.3.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "pest"
+version = "2.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9"
 dependencies = [
- "ppv-lite86",
- "rand_core 0.9.5",
+ "memchr",
+ "ucd-trie",
 ]
 
 [[package]]
-name = "rand_core"
-version = "0.6.4"
+name = "pest_derive"
+version = "2.8.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+checksum = "4b4254325ecad416ab689e27ba51da03ba01a9632bc6e108f5fe7c3c4ad29d58"
 dependencies = [
- "getrandom 0.2.17",
+ "pest",
+ "pest_generator",
 ]
 
 [[package]]
-name = "rand_core"
-version = "0.9.5"
+name = "pest_generator"
+version = "2.8.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+checksum = "6c4c0e91ead7a8f7acecbca6f003fc2e8282b1dbe2dd9c9d2f16aba42995e0a7"
 dependencies = [
- "getrandom 0.3.4",
+ "pest",
+ "pest_meta",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
 ]
 
 [[package]]
-name = "ratatui"
-version = "0.29.0"
+name = "pest_meta"
+version = "2.8.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b"
+checksum = "f9744bc48116fee06334924bb5f2bad41eed5e89bd26e29b0b799f9a3f82c210"
 dependencies = [
- "bitflags",
- "cassowary",
- "compact_str",
- "crossterm 0.28.1",
- "indoc",
- "instability",
- "itertools 0.13.0",
- "lru",
- "paste",
- "strum",
- "unicode-segmentation",
- "unicode-truncate",
- "unicode-width 0.2.0",
+ "pest",
 ]
 
 [[package]]
-name = "redox_syscall"
-version = "0.5.18"
+name = "petgraph"
+version = "0.7.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772"
 dependencies = [
- "bitflags",
+ "fixedbitset 0.5.7",
+ "indexmap 2.13.0",
 ]
 
 [[package]]
-name = "redox_users"
-version = "0.4.6"
+name = "petgraph"
+version = "0.8.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
+checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
 dependencies = [
- "getrandom 0.2.17",
- "libredox",
- "thiserror 1.0.69",
+ "fixedbitset 0.5.7",
+ "hashbrown 0.15.5",
+ "indexmap 2.13.0",
+ "serde",
 ]
 
 [[package]]
-name = "ref-cast"
-version = "1.0.25"
+name = "phf"
+version = "0.11.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d"
+checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
 dependencies = [
- "ref-cast-impl",
+ "phf_macros 0.11.3",
+ "phf_shared 0.11.3",
 ]
 
 [[package]]
-name = "ref-cast-impl"
-version = "1.0.25"
+name = "phf"
+version = "0.12.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da"
+checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7"
 dependencies = [
- "proc-macro2",
- "quote",
- "syn",
+ "phf_macros 0.12.1",
+ "phf_shared 0.12.1",
+ "serde",
 ]
 
 [[package]]
-name = "regex"
-version = "1.12.3"
+name = "phf_codegen"
+version = "0.11.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
+checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
 dependencies = [
- "aho-corasick",
- "memchr",
- "regex-automata",
- "regex-syntax",
+ "phf_generator 0.11.3",
+ "phf_shared 0.11.3",
 ]
 
 [[package]]
-name = "regex-automata"
-version = "0.4.14"
+name = "phf_generator"
+version = "0.11.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
+checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
 dependencies = [
- "aho-corasick",
- "memchr",
- "regex-syntax",
+ "phf_shared 0.11.3",
+ "rand 0.8.5",
+]
+
+[[package]]
+name = "phf_generator"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2cbb1126afed61dd6368748dae63b1ee7dc480191c6262a3b4ff1e29d86a6c5b"
+dependencies = [
+ "fastrand",
+ "phf_shared 0.12.1",
+]
+
+[[package]]
+name = "phf_macros"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
+dependencies = [
+ "phf_generator 0.11.3",
+ "phf_shared 0.11.3",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "phf_macros"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d713258393a82f091ead52047ca779d37e5766226d009de21696c4e667044368"
+dependencies = [
+ "phf_generator 0.12.1",
+ "phf_shared 0.12.1",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
+dependencies = [
+ "siphasher 1.0.3",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"
+dependencies = [
+ "siphasher 1.0.3",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
+
+[[package]]
+name = "pin-utils"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
+
+[[package]]
+name = "pkg-config"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
+
+[[package]]
+name = "portable-atomic"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
+
+[[package]]
+name = "postcard"
+version = "1.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
+dependencies = [
+ "cobs",
+ "embedded-io 0.4.0",
+ "embedded-io 0.6.1",
+ "serde",
+]
+
+[[package]]
+name = "potential_utf"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "predicates"
+version = "3.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe"
+dependencies = [
+ "anstyle",
+ "difflib",
+ "float-cmp",
+ "normalize-line-endings",
+ "predicates-core",
+ "regex",
+]
+
+[[package]]
+name = "predicates-core"
+version = "1.0.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144"
+
+[[package]]
+name = "predicates-tree"
+version = "1.0.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2"
+dependencies = [
+ "predicates-core",
+ "termtree",
+]
+
+[[package]]
+name = "prettyplease"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
+dependencies = [
+ "proc-macro2",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "prost"
+version = "0.13.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5"
+dependencies = [
+ "bytes",
+ "prost-derive",
+]
+
+[[package]]
+name = "prost-build"
+version = "0.13.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf"
+dependencies = [
+ "heck",
+ "itertools 0.14.0",
+ "log",
+ "multimap",
+ "once_cell",
+ "petgraph 0.7.1",
+ "prettyplease",
+ "prost",
+ "prost-types",
+ "regex",
+ "syn 2.0.117",
+ "tempfile",
+]
+
+[[package]]
+name = "prost-derive"
+version = "0.13.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
+dependencies = [
+ "anyhow",
+ "itertools 0.14.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "prost-types"
+version = "0.13.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16"
+dependencies = [
+ "prost",
+]
+
+[[package]]
+name = "psm"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "645dbe486e346d9b5de3ef16ede18c26e6c70ad97418f4874b8b1889d6e761ea"
+dependencies = [
+ "ar_archive_writer",
+ "cc",
+]
+
+[[package]]
+name = "quinn"
+version = "0.11.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
+dependencies = [
+ "bytes",
+ "cfg_aliases",
+ "pin-project-lite",
+ "quinn-proto",
+ "quinn-udp",
+ "rustc-hash",
+ "rustls",
+ "socket2 0.6.2",
+ "thiserror 2.0.18",
+ "tokio",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-proto"
+version = "0.11.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
+dependencies = [
+ "bytes",
+ "getrandom 0.3.4",
+ "lru-slab",
+ "rand 0.9.2",
+ "ring",
+ "rustc-hash",
+ "rustls",
+ "rustls-pki-types",
+ "slab",
+ "thiserror 2.0.18",
+ "tinyvec",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-udp"
+version = "0.5.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
+dependencies = [
+ "cfg_aliases",
+ "libc",
+ "once_cell",
+ "socket2 0.6.2",
+ "tracing",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "radix_fmt"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce082a9940a7ace2ad4a8b7d0b1eac6aa378895f18be598230c5f2284ac05426"
+
+[[package]]
+name = "rand"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
+dependencies = [
+ "libc",
+ "rand_chacha 0.3.1",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
+dependencies = [
+ "rand_chacha 0.9.0",
+ "rand_core 0.9.5",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.9.5",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom 0.2.17",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+dependencies = [
+ "getrandom 0.3.4",
+]
+
+[[package]]
+name = "ratatui"
+version = "0.30.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d"
+dependencies = [
+ "instability",
+ "ratatui-core",
+ "ratatui-crossterm",
+ "ratatui-macros",
+ "ratatui-termina",
+ "ratatui-termwiz",
+ "ratatui-widgets",
+ "serde",
+]
+
+[[package]]
+name = "ratatui-core"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c"
+dependencies = [
+ "bitflags 2.13.0",
+ "compact_str 0.9.1",
+ "critical-section",
+ "hashbrown 0.17.1",
+ "itertools 0.14.0",
+ "kasuari",
+ "lru",
+ "palette",
+ "serde",
+ "strum",
+ "thiserror 2.0.18",
+ "unicode-segmentation",
+ "unicode-truncate",
+ "unicode-width 0.2.2",
+]
+
+[[package]]
+name = "ratatui-crossterm"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0"
+dependencies = [
+ "cfg-if",
+ "crossterm 0.29.0",
+ "instability",
+ "ratatui-core",
+]
+
+[[package]]
+name = "ratatui-macros"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814"
+dependencies = [
+ "ratatui-core",
+ "ratatui-widgets",
+]
+
+[[package]]
+name = "ratatui-termina"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2"
+dependencies = [
+ "instability",
+ "ratatui-core",
+ "termina",
+]
+
+[[package]]
+name = "ratatui-termwiz"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977"
+dependencies = [
+ "ratatui-core",
+ "termwiz",
+]
+
+[[package]]
+name = "ratatui-widgets"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1"
+dependencies = [
+ "bitflags 2.13.0",
+ "hashbrown 0.17.1",
+ "indoc",
+ "instability",
+ "itertools 0.14.0",
+ "line-clipping",
+ "ratatui-core",
+ "serde",
+ "strum",
+ "time",
+ "unicode-segmentation",
+ "unicode-width 0.2.2",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags 2.13.0",
+]
+
+[[package]]
+name = "redox_users"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
+dependencies = [
+ "getrandom 0.2.17",
+ "libredox",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "ref-cast"
+version = "1.0.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d"
+dependencies = [
+ "ref-cast-impl",
+]
+
+[[package]]
+name = "ref-cast-impl"
+version = "1.0.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "regex"
+version = "1.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
 ]
 
 [[package]]
 name = "regex-lite"
 version = "0.1.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973"
+checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973"
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
+
+[[package]]
+name = "relative-path"
+version = "1.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2"
+
+[[package]]
+name = "reqwest"
+version = "0.12.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "futures-core",
+ "futures-util",
+ "http 1.4.0",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-rustls",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "mime_guess",
+ "percent-encoding",
+ "pin-project-lite",
+ "quinn",
+ "rustls",
+ "rustls-native-certs",
+ "rustls-pki-types",
+ "serde",
+ "serde_json 1.0.149",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tokio-rustls",
+ "tokio-util",
+ "tower",
+ "tower-http",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wasm-streams",
+ "web-sys",
+ "webpki-roots",
+]
+
+[[package]]
+name = "ring"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom 0.2.17",
+ "libc",
+ "untrusted",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "ropey"
+version = "1.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93411e420bcd1a75ddd1dc3caf18c23155eda2c090631a85af21ba19e97093b5"
+dependencies = [
+ "smallvec",
+ "str_indices",
+]
+
+[[package]]
+name = "rustc-hash"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
+
+[[package]]
+name = "rustc_version"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
+dependencies = [
+ "semver",
+]
+
+[[package]]
+name = "rustix"
+version = "0.38.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
+dependencies = [
+ "bitflags 2.13.0",
+ "errno",
+ "libc",
+ "linux-raw-sys 0.4.15",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags 2.13.0",
+ "errno",
+ "libc",
+ "linux-raw-sys 0.12.1",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustls"
+version = "0.23.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
+dependencies = [
+ "once_cell",
+ "ring",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-native-certs"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
+dependencies = [
+ "openssl-probe",
+ "rustls-pki-types",
+ "schannel",
+ "security-framework",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
+dependencies = [
+ "web-time",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
+dependencies = [
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "schannel"
+version = "0.1.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "schemars"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
+dependencies = [
+ "dyn-clone",
+ "ref-cast",
+ "serde",
+ "serde_json 1.0.149",
+]
+
+[[package]]
+name = "schemars"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc"
+dependencies = [
+ "dyn-clone",
+ "ref-cast",
+ "serde",
+ "serde_json 1.0.149",
+]
+
+[[package]]
+name = "scoped-tls"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "security-framework"
+version = "3.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
+dependencies = [
+ "bitflags 2.13.0",
+ "core-foundation",
+ "core-foundation-sys",
+ "libc",
+ "security-framework-sys",
+]
+
+[[package]]
+name = "security-framework-sys"
+version = "2.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "self_cell"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89"
+
+[[package]]
+name = "semver"
+version = "1.0.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
+
+[[package]]
+name = "seq-macro"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc"
 
 [[package]]
-name = "regex-syntax"
-version = "0.8.10"
+name = "serde"
+version = "1.0.228"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
 
 [[package]]
-name = "reqwest"
-version = "0.12.28"
+name = "serde-wasm-bindgen"
+version = "0.6.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
+checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b"
 dependencies = [
- "base64 0.22.1",
- "bytes",
- "futures-core",
- "futures-util",
- "http 1.4.0",
- "http-body",
- "http-body-util",
- "hyper",
- "hyper-rustls",
- "hyper-util",
  "js-sys",
- "log",
- "mime_guess",
- "percent-encoding",
- "pin-project-lite",
- "quinn",
- "rustls",
- "rustls-native-certs",
- "rustls-pki-types",
  "serde",
- "serde_json 1.0.149",
- "serde_urlencoded",
- "sync_wrapper",
- "tokio",
- "tokio-rustls",
- "tokio-util",
- "tower",
- "tower-http",
- "tower-service",
- "url",
  "wasm-bindgen",
- "wasm-bindgen-futures",
- "wasm-streams",
- "web-sys",
- "webpki-roots",
 ]
 
 [[package]]
-name = "ring"
-version = "0.17.14"
+name = "serde_core"
+version = "1.0.228"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
 dependencies = [
- "cc",
- "cfg-if",
- "getrandom 0.2.17",
- "libc",
- "untrusted",
- "windows-sys 0.52.0",
+ "serde_derive",
 ]
 
 [[package]]
-name = "rustc-hash"
-version = "2.1.1"
+name = "serde_derive"
+version = "1.0.228"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
 
 [[package]]
-name = "rustc_version"
-version = "0.4.1"
+name = "serde_json"
+version = "1.0.145"
+source = "git+https://github.com/ankrgyl/serde-json.git?rev=efa66e3a1d61459ab2d325f92ebe3acbd6ca18b1#efa66e3a1d61459ab2d325f92ebe3acbd6ca18b1"
+dependencies = [
+ "itoa",
+ "memchr",
+ "ryu",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.149"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
 dependencies = [
- "semver",
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
 ]
 
 [[package]]
-name = "rustix"
-version = "0.38.44"
+name = "serde_path_to_error"
+version = "0.1.20"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
+checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
 dependencies = [
- "bitflags",
- "errno",
- "libc",
- "linux-raw-sys 0.4.15",
- "windows-sys 0.59.0",
+ "itoa",
+ "serde",
+ "serde_core",
 ]
 
 [[package]]
-name = "rustix"
-version = "1.1.4"
+name = "serde_repr"
+version = "0.1.20"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "serde_spanned"
+version = "0.6.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "serde_with"
+version = "3.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "381b283ce7bc6b476d903296fb59d0d36633652b633b27f64db4fb46dcbfc3b9"
+dependencies = [
+ "base64 0.22.1",
+ "chrono",
+ "hex",
+ "indexmap 1.9.3",
+ "indexmap 2.13.0",
+ "schemars 0.9.0",
+ "schemars 1.2.1",
+ "serde_core",
+ "serde_json 1.0.149",
+ "serde_with_macros",
+ "time",
+]
+
+[[package]]
+name = "serde_with_macros"
+version = "3.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0"
+dependencies = [
+ "darling 0.21.3",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "serde_yaml"
+version = "0.9.34+deprecated"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
+dependencies = [
+ "indexmap 2.13.0",
+ "itoa",
+ "ryu",
+ "serde",
+ "unsafe-libyaml",
+]
+
+[[package]]
+name = "sha1"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "shell-words"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
+
+[[package]]
+name = "shlex"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+
+[[package]]
+name = "signal-hook"
+version = "0.3.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
 dependencies = [
- "bitflags",
- "errno",
  "libc",
- "linux-raw-sys 0.12.1",
- "windows-sys 0.59.0",
+ "signal-hook-registry",
 ]
 
 [[package]]
-name = "rustls"
-version = "0.23.37"
+name = "signal-hook-mio"
+version = "0.2.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
+checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
 dependencies = [
- "once_cell",
- "ring",
- "rustls-pki-types",
- "rustls-webpki",
- "subtle",
- "zeroize",
+ "libc",
+ "mio",
+ "signal-hook",
 ]
 
 [[package]]
-name = "rustls-native-certs"
-version = "0.8.3"
+name = "signal-hook-registry"
+version = "1.4.8"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
 dependencies = [
- "openssl-probe",
- "rustls-pki-types",
- "schannel",
- "security-framework",
+ "errno",
+ "libc",
 ]
 
 [[package]]
-name = "rustls-pki-types"
-version = "1.14.0"
+name = "simd-adler32"
+version = "0.3.8"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
-dependencies = [
- "web-time",
- "zeroize",
-]
+checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
 
 [[package]]
-name = "rustls-webpki"
-version = "0.103.13"
+name = "siphasher"
+version = "0.3.11"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
-dependencies = [
- "ring",
- "rustls-pki-types",
- "untrusted",
-]
+checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d"
 
 [[package]]
-name = "rustversion"
-version = "1.0.22"
+name = "siphasher"
+version = "1.0.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
 
 [[package]]
-name = "ryu"
-version = "1.0.23"
+name = "slab"
+version = "0.4.12"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
 
 [[package]]
-name = "schannel"
-version = "0.1.29"
+name = "smallvec"
+version = "1.15.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+
+[[package]]
+name = "smartstring"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29"
 dependencies = [
- "windows-sys 0.61.2",
+ "autocfg",
+ "static_assertions",
+ "version_check",
 ]
 
 [[package]]
-name = "schemars"
-version = "0.9.0"
+name = "smawk"
+version = "0.3.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
+checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100"
+
+[[package]]
+name = "socket2"
+version = "0.5.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
 dependencies = [
- "dyn-clone",
- "ref-cast",
- "serde",
- "serde_json 1.0.149",
+ "libc",
+ "windows-sys 0.52.0",
 ]
 
 [[package]]
-name = "schemars"
-version = "1.2.1"
+name = "socket2"
+version = "0.6.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc"
+checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0"
 dependencies = [
- "dyn-clone",
- "ref-cast",
- "serde",
- "serde_json 1.0.149",
+ "libc",
+ "windows-sys 0.60.2",
 ]
 
 [[package]]
-name = "scopeguard"
-version = "1.2.0"
+name = "stable_deref_trait"
+version = "1.2.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
 
 [[package]]
-name = "security-framework"
-version = "3.7.0"
+name = "stacker"
+version = "0.1.24"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
+checksum = "640c8cdd92b6b12f5bcb1803ca3bbf5ab96e5e6b6b96b9ab77dabe9e880b3190"
 dependencies = [
- "bitflags",
- "core-foundation",
- "core-foundation-sys",
+ "cc",
+ "cfg-if",
  "libc",
- "security-framework-sys",
+ "psm",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
-name = "security-framework-sys"
-version = "2.17.0"
+name = "static_assertions"
+version = "1.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
-dependencies = [
- "core-foundation-sys",
- "libc",
-]
+checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
 
 [[package]]
-name = "semver"
-version = "1.0.27"
+name = "str_indices"
+version = "0.4.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
+checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6"
 
 [[package]]
-name = "serde"
-version = "1.0.228"
+name = "string_enum"
+version = "1.0.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+checksum = "ae36a4951ca7bd1cfd991c241584a9824a70f6aff1e7d4f693fb3f2465e4030e"
 dependencies = [
- "serde_core",
- "serde_derive",
+ "quote",
+ "swc_macros_common",
+ "syn 2.0.117",
 ]
 
 [[package]]
-name = "serde-wasm-bindgen"
-version = "0.6.5"
+name = "strip-ansi-escapes"
+version = "0.2.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b"
+checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025"
 dependencies = [
- "js-sys",
- "serde",
- "wasm-bindgen",
+ "vte",
 ]
 
 [[package]]
-name = "serde_core"
-version = "1.0.228"
+name = "strsim"
+version = "0.11.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "strum"
+version = "0.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
 dependencies = [
- "serde_derive",
+ "strum_macros",
 ]
 
 [[package]]
-name = "serde_derive"
-version = "1.0.228"
+name = "strum_macros"
+version = "0.28.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
 dependencies = [
+ "heck",
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.117",
 ]
 
 [[package]]
-name = "serde_json"
-version = "1.0.145"
-source = "git+https://github.com/ankrgyl/serde-json.git?rev=efa66e3a1d61459ab2d325f92ebe3acbd6ca18b1#efa66e3a1d61459ab2d325f92ebe3acbd6ca18b1"
-dependencies = [
- "itoa",
- "memchr",
- "ryu",
- "serde",
- "serde_core",
-]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
 
 [[package]]
-name = "serde_json"
-version = "1.0.149"
+name = "swc_allocator"
+version = "4.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
+checksum = "9d7eefd2c8b228a8c73056482b2ae4b3a1071fbe07638e3b55ceca8570cc48bb"
 dependencies = [
- "itoa",
- "memchr",
- "serde",
- "serde_core",
- "zmij",
+ "allocator-api2",
+ "bumpalo",
+ "hashbrown 0.14.5",
+ "rustc-hash",
 ]
 
 [[package]]
-name = "serde_path_to_error"
-version = "0.1.20"
+name = "swc_atoms"
+version = "9.0.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
+checksum = "845f31910b5236db42dba106e8277681098d183b9b65b8dfa88ca8abe464aeff"
 dependencies = [
- "itoa",
+ "hstr",
+ "once_cell",
  "serde",
- "serde_core",
 ]
 
 [[package]]
-name = "serde_repr"
-version = "0.1.20"
+name = "swc_bundler"
+version = "49.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
+checksum = "47950970ceed8a5b80e6f0702bbd72a2ebf5205bedb36438863209b4ad58a5a7"
 dependencies = [
- "proc-macro2",
- "quote",
- "syn",
+ "anyhow",
+ "crc",
+ "indexmap 2.13.0",
+ "is-macro",
+ "once_cell",
+ "petgraph 0.7.1",
+ "radix_fmt",
+ "relative-path",
+ "rustc-hash",
+ "swc_atoms",
+ "swc_common 21.0.2",
+ "swc_ecma_ast 23.0.1",
+ "swc_ecma_codegen",
+ "swc_ecma_loader",
+ "swc_ecma_parser",
+ "swc_ecma_transforms_base",
+ "swc_ecma_transforms_optimization",
+ "swc_ecma_utils",
+ "swc_ecma_visit 23.0.0",
+ "swc_graph_analyzer",
+ "tracing",
 ]
 
 [[package]]
-name = "serde_spanned"
-version = "0.6.9"
+name = "swc_common"
+version = "20.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
+checksum = "990735f1c65fac4d0f2748113f3bc8a799fd31501efbd254eb7cc5e877c50660"
 dependencies = [
+ "anyhow",
+ "ast_node",
+ "better_scoped_tls",
+ "bytes-str",
+ "either",
+ "from_variant",
+ "num-bigint",
+ "once_cell",
+ "rustc-hash",
  "serde",
+ "siphasher 0.3.11",
+ "swc_atoms",
+ "swc_eq_ignore_macros",
+ "swc_visit",
+ "tracing",
+ "unicode-width 0.2.2",
+ "url",
 ]
 
 [[package]]
-name = "serde_urlencoded"
-version = "0.7.1"
+name = "swc_common"
+version = "21.0.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+checksum = "da38f2cee8e659bf0ec7f51ec5b37ec58c9127de755d3fe0b2c2353ec9474b09"
 dependencies = [
- "form_urlencoded",
- "itoa",
- "ryu",
+ "anyhow",
+ "ast_node",
+ "better_scoped_tls",
+ "bytes-str",
+ "either",
+ "from_variant",
+ "num-bigint",
+ "once_cell",
+ "rustc-hash",
  "serde",
+ "siphasher 0.3.11",
+ "swc_atoms",
+ "swc_eq_ignore_macros",
+ "swc_visit",
+ "tracing",
+ "unicode-width 0.2.2",
+ "url",
 ]
 
 [[package]]
-name = "serde_with"
-version = "3.17.0"
+name = "swc_config"
+version = "5.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "381b283ce7bc6b476d903296fb59d0d36633652b633b27f64db4fb46dcbfc3b9"
+checksum = "6ee60aaa4eaea81fb9c730e64a0b88c363b5c237b774d114c97cba82f8655fdb"
 dependencies = [
- "base64 0.22.1",
- "chrono",
- "hex",
- "indexmap 1.9.3",
+ "anyhow",
+ "bytes-str",
  "indexmap 2.13.0",
- "schemars 0.9.0",
- "schemars 1.2.1",
- "serde_core",
+ "serde",
  "serde_json 1.0.149",
- "serde_with_macros",
- "time",
+ "swc_config_macro",
 ]
 
 [[package]]
-name = "serde_with_macros"
-version = "3.17.0"
+name = "swc_config_macro"
+version = "1.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0"
+checksum = "7b416e8ce6de17dc5ea496e10c7012b35bbc0e3fef38d2e065eed936490db0b3"
 dependencies = [
- "darling 0.21.3",
  "proc-macro2",
  "quote",
- "syn",
+ "swc_macros_common",
+ "syn 2.0.117",
 ]
 
 [[package]]
-name = "serde_yaml"
-version = "0.9.34+deprecated"
+name = "swc_ecma_ast"
+version = "22.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
+checksum = "acb0eed1bac5703d661d16fa4b54958f6b8823a6f1385d163942cf4b90afb4a2"
 dependencies = [
- "indexmap 2.13.0",
- "itoa",
- "ryu",
- "serde",
- "unsafe-libyaml",
+ "bitflags 2.13.0",
+ "is-macro",
+ "num-bigint",
+ "once_cell",
+ "phf 0.11.3",
+ "rustc-hash",
+ "string_enum",
+ "swc_atoms",
+ "swc_common 20.0.0",
+ "swc_visit",
+ "unicode-id-start",
 ]
 
 [[package]]
-name = "sha1"
-version = "0.10.6"
+name = "swc_ecma_ast"
+version = "23.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
+checksum = "550ee54eab536fe357090fec6d42d083c28cf44cc9bcfa93b1ea5e1606f3b2f7"
 dependencies = [
- "cfg-if",
- "cpufeatures",
- "digest",
+ "bitflags 2.13.0",
+ "is-macro",
+ "num-bigint",
+ "once_cell",
+ "phf 0.11.3",
+ "rustc-hash",
+ "string_enum",
+ "swc_atoms",
+ "swc_common 21.0.2",
+ "swc_visit",
+ "unicode-id-start",
 ]
 
 [[package]]
-name = "sha2"
-version = "0.10.9"
+name = "swc_ecma_codegen"
+version = "26.0.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+checksum = "39230b073d1d785ac7a905354161e21970d15956e348f46a85deb0da0d4d5132"
 dependencies = [
- "cfg-if",
- "cpufeatures",
- "digest",
+ "ascii",
+ "compact_str 0.7.1",
+ "dragonbox_ecma 0.1.12",
+ "memchr",
+ "num-bigint",
+ "once_cell",
+ "regex",
+ "rustc-hash",
+ "serde",
+ "swc_allocator",
+ "swc_atoms",
+ "swc_common 21.0.2",
+ "swc_ecma_ast 23.0.1",
+ "swc_ecma_codegen_macros",
+ "tracing",
 ]
 
 [[package]]
-name = "shell-words"
-version = "1.1.1"
+name = "swc_ecma_codegen_macros"
+version = "2.0.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
+checksum = "e276dc62c0a2625a560397827989c82a93fd545fcf6f7faec0935a82cc4ddbb8"
+dependencies = [
+ "proc-macro2",
+ "swc_macros_common",
+ "syn 2.0.117",
+]
 
 [[package]]
-name = "shlex"
-version = "1.3.0"
+name = "swc_ecma_hooks"
+version = "0.7.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+checksum = "ee7662517362f6726b0e107a3caec2b4cc7d629f9bf702958948e9350722e52f"
+dependencies = [
+ "swc_atoms",
+ "swc_common 21.0.2",
+ "swc_ecma_ast 23.0.1",
+ "swc_ecma_visit 23.0.0",
+]
 
 [[package]]
-name = "signal-hook"
-version = "0.3.18"
+name = "swc_ecma_loader"
+version = "22.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
+checksum = "562c983a163ca2a016626d03f037390a9c1de8f004605ecf44f4fa92a292ae42"
 dependencies = [
- "libc",
- "signal-hook-registry",
+ "anyhow",
+ "dashmap",
+ "normpath",
+ "once_cell",
+ "path-clean",
+ "pathdiff",
+ "rustc-hash",
+ "serde",
+ "serde_json 1.0.149",
+ "swc_atoms",
+ "swc_common 21.0.2",
+ "tracing",
 ]
 
 [[package]]
-name = "signal-hook-mio"
-version = "0.2.5"
+name = "swc_ecma_parser"
+version = "39.1.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
+checksum = "bca883cdbd6107a96f60a23fd90623c9a90cf37741fc08a3337cee9bbd6c4c1a"
 dependencies = [
- "libc",
- "mio",
- "signal-hook",
+ "bitflags 2.13.0",
+ "either",
+ "num-bigint",
+ "phf 0.11.3",
+ "rustc-hash",
+ "seq-macro",
+ "serde",
+ "smartstring",
+ "stacker",
+ "swc_atoms",
+ "swc_common 21.0.2",
+ "swc_ecma_ast 23.0.1",
+ "tracing",
 ]
 
 [[package]]
-name = "signal-hook-registry"
-version = "1.4.8"
+name = "swc_ecma_transforms_base"
+version = "42.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+checksum = "1f0c8ee943a8f9099391cecef5b3eafc98aba64dfa5f6f7cd336a32989d92d1a"
 dependencies = [
- "errno",
- "libc",
+ "better_scoped_tls",
+ "indexmap 2.13.0",
+ "once_cell",
+ "par-core",
+ "phf 0.11.3",
+ "rustc-hash",
+ "serde",
+ "swc_atoms",
+ "swc_common 21.0.2",
+ "swc_ecma_ast 23.0.1",
+ "swc_ecma_parser",
+ "swc_ecma_utils",
+ "swc_ecma_visit 23.0.0",
+ "tracing",
 ]
 
 [[package]]
-name = "simd-adler32"
-version = "0.3.8"
+name = "swc_ecma_transforms_optimization"
+version = "45.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
+checksum = "bf10153f1eb750745cf0a28b2261dd32c0f0dba011b73cd1ea13256e90f1a784"
+dependencies = [
+ "bytes-str",
+ "dashmap",
+ "indexmap 2.13.0",
+ "once_cell",
+ "par-core",
+ "petgraph 0.7.1",
+ "rustc-hash",
+ "serde_json 1.0.149",
+ "swc_atoms",
+ "swc_common 21.0.2",
+ "swc_ecma_ast 23.0.1",
+ "swc_ecma_parser",
+ "swc_ecma_transforms_base",
+ "swc_ecma_utils",
+ "swc_ecma_visit 23.0.0",
+ "tracing",
+]
 
 [[package]]
-name = "slab"
-version = "0.4.12"
+name = "swc_ecma_transforms_react"
+version = "47.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+checksum = "8989e4d64e0d219f1c471c4ecd2190bab717a15ad48c4ce3ba603f6501fc551d"
+dependencies = [
+ "base64 0.22.1",
+ "bytes-str",
+ "indexmap 2.13.0",
+ "once_cell",
+ "rustc-hash",
+ "serde",
+ "sha1",
+ "string_enum",
+ "swc_atoms",
+ "swc_common 21.0.2",
+ "swc_config",
+ "swc_ecma_ast 23.0.1",
+ "swc_ecma_hooks",
+ "swc_ecma_parser",
+ "swc_ecma_transforms_base",
+ "swc_ecma_utils",
+ "swc_ecma_visit 23.0.0",
+]
 
 [[package]]
-name = "smallvec"
-version = "1.15.1"
+name = "swc_ecma_transforms_typescript"
+version = "47.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+checksum = "f4db96f7f292ecb4c819aff462a7965ddcba88ec7cc2705d2ee45db7eff56c77"
+dependencies = [
+ "bytes-str",
+ "rustc-hash",
+ "serde",
+ "swc_atoms",
+ "swc_common 21.0.2",
+ "swc_ecma_ast 23.0.1",
+ "swc_ecma_transforms_base",
+ "swc_ecma_transforms_react",
+ "swc_ecma_utils",
+ "swc_ecma_visit 23.0.0",
+]
 
 [[package]]
-name = "socket2"
-version = "0.5.10"
+name = "swc_ecma_utils"
+version = "29.1.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
+checksum = "3d69b480aa02b5ff2ab951478d8e7633eeda42940aeb5fe0386eebc19dd3b1e4"
 dependencies = [
- "libc",
- "windows-sys 0.52.0",
+ "dragonbox_ecma 0.1.12",
+ "indexmap 2.13.0",
+ "num_cpus",
+ "once_cell",
+ "par-core",
+ "rustc-hash",
+ "swc_atoms",
+ "swc_common 21.0.2",
+ "swc_ecma_ast 23.0.1",
+ "swc_ecma_visit 23.0.0",
+ "tracing",
 ]
 
 [[package]]
-name = "socket2"
-version = "0.6.2"
+name = "swc_ecma_visit"
+version = "22.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0"
+checksum = "d9ead1b61949ce19ca2088ae0cac092bfa2287718c955ff31e55f7d1eba0a84b"
 dependencies = [
- "libc",
- "windows-sys 0.60.2",
+ "new_debug_unreachable",
+ "num-bigint",
+ "swc_atoms",
+ "swc_common 20.0.0",
+ "swc_ecma_ast 22.0.0",
+ "swc_visit",
+ "tracing",
 ]
 
 [[package]]
-name = "stable_deref_trait"
-version = "1.2.1"
+name = "swc_ecma_visit"
+version = "23.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+checksum = "ad65d392ed427dc9e94f16a3d802b02e27722c21227639c8d5f45f19757b447b"
+dependencies = [
+ "new_debug_unreachable",
+ "num-bigint",
+ "swc_atoms",
+ "swc_common 21.0.2",
+ "swc_ecma_ast 23.0.1",
+ "swc_visit",
+ "tracing",
+]
 
 [[package]]
-name = "static_assertions"
-version = "1.1.0"
+name = "swc_eq_ignore_macros"
+version = "1.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+checksum = "c16ce73424a6316e95e09065ba6a207eba7765496fed113702278b7711d4b632"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
 
 [[package]]
-name = "strip-ansi-escapes"
-version = "0.2.1"
+name = "swc_graph_analyzer"
+version = "14.0.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025"
+checksum = "5524b7dd51c772d1154feb46d177c0d938d32df8c6a1546cc71f89ce47895d2f"
 dependencies = [
- "vte",
+ "auto_impl",
+ "petgraph 0.7.1",
+ "rustc-hash",
+ "tracing",
 ]
 
 [[package]]
-name = "strsim"
-version = "0.11.1"
+name = "swc_macros_common"
+version = "1.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+checksum = "aae1efbaa74943dc5ad2a2fb16cbd78b77d7e4d63188f3c5b4df2b4dcd2faaae"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
 
 [[package]]
-name = "strum"
-version = "0.26.3"
+name = "swc_visit"
+version = "2.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
+checksum = "62fb71484b486c185e34d2172f0eabe7f4722742aad700f426a494bb2de232a2"
 dependencies = [
- "strum_macros",
+ "either",
+ "new_debug_unreachable",
 ]
 
 [[package]]
-name = "strum_macros"
-version = "0.26.4"
+name = "syn"
+version = "1.0.109"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
+checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
 dependencies = [
- "heck",
  "proc-macro2",
  "quote",
- "rustversion",
- "syn",
+ "unicode-ident",
 ]
 
-[[package]]
-name = "subtle"
-version = "2.6.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
-
 [[package]]
 name = "syn"
 version = "2.0.117"
@@ -3062,7 +4797,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -3075,7 +4810,7 @@ dependencies = [
  "getrandom 0.4.1",
  "once_cell",
  "rustix 1.1.4",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -3087,12 +4822,99 @@ dependencies = [
  "winapi-util",
 ]
 
+[[package]]
+name = "termina"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e"
+dependencies = [
+ "bitflags 2.13.0",
+ "parking_lot",
+ "rustix 1.1.4",
+ "signal-hook",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "terminfo"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662"
+dependencies = [
+ "fnv",
+ "nom",
+ "phf 0.11.3",
+ "phf_codegen",
+]
+
+[[package]]
+name = "termios"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b"
+dependencies = [
+ "libc",
+]
+
 [[package]]
 name = "termtree"
 version = "0.5.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
 
+[[package]]
+name = "termwiz"
+version = "0.23.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7"
+dependencies = [
+ "anyhow",
+ "base64 0.22.1",
+ "bitflags 2.13.0",
+ "fancy-regex",
+ "filedescriptor",
+ "finl_unicode",
+ "fixedbitset 0.4.2",
+ "hex",
+ "lazy_static",
+ "libc",
+ "log",
+ "memmem",
+ "nix",
+ "num-derive",
+ "num-traits",
+ "ordered-float",
+ "pest",
+ "pest_derive",
+ "phf 0.11.3",
+ "sha2",
+ "signal-hook",
+ "siphasher 1.0.3",
+ "terminfo",
+ "termios",
+ "thiserror 1.0.69",
+ "ucd-trie",
+ "unicode-segmentation",
+ "vtparse",
+ "wezterm-bidi",
+ "wezterm-blob-leases",
+ "wezterm-color-types",
+ "wezterm-dynamic",
+ "wezterm-input-types",
+ "winapi",
+]
+
+[[package]]
+name = "textwrap"
+version = "0.16.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
+dependencies = [
+ "smawk",
+ "unicode-linebreak",
+ "unicode-width 0.2.2",
+]
+
 [[package]]
 name = "thiserror"
 version = "1.0.69"
@@ -3119,7 +4941,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -3130,7 +4952,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -3150,7 +4972,9 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
 dependencies = [
  "deranged",
  "itoa",
+ "libc",
  "num-conv",
+ "num_threads",
  "powerfmt",
  "serde_core",
  "time-core",
@@ -3223,7 +5047,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -3312,7 +5136,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
 dependencies = [
  "async-compression",
- "bitflags",
+ "bitflags 2.13.0",
  "bytes",
  "futures-core",
  "futures-util",
@@ -3360,7 +5184,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -3372,6 +5196,16 @@ dependencies = [
  "once_cell",
 ]
 
+[[package]]
+name = "triomphe"
+version = "0.1.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b40688ea6389c8171614b25491f71d4a27946e0c7ce2da1c6de27e25abf1a0ae"
+dependencies = [
+ "serde",
+ "stable_deref_trait",
+]
+
 [[package]]
 name = "try-lock"
 version = "0.2.5"
@@ -3396,7 +5230,7 @@ checksum = "ee6ff59666c9cbaec3533964505d39154dc4e0a56151fdea30a09ed0301f62e2"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.117",
  "termcolor",
 ]
 
@@ -3406,18 +5240,36 @@ version = "1.19.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
 
+[[package]]
+name = "ucd-trie"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
+
 [[package]]
 name = "unicase"
 version = "2.9.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
 
+[[package]]
+name = "unicode-id-start"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "81b79ad29b5e19de4260020f8919b443b2ef0277d242ce532ec7b7a2cc8b6007"
+
 [[package]]
 name = "unicode-ident"
 version = "1.0.24"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
 
+[[package]]
+name = "unicode-linebreak"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
+
 [[package]]
 name = "unicode-segmentation"
 version = "1.12.0"
@@ -3426,13 +5278,13 @@ checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
 
 [[package]]
 name = "unicode-truncate"
-version = "1.1.0"
+version = "2.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
+checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5"
 dependencies = [
- "itertools 0.13.0",
+ "itertools 0.14.0",
  "unicode-segmentation",
- "unicode-width 0.1.14",
+ "unicode-width 0.2.2",
 ]
 
 [[package]]
@@ -3443,9 +5295,9 @@ checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
 
 [[package]]
 name = "unicode-width"
-version = "0.2.0"
+version = "0.2.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
+checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
 
 [[package]]
 name = "unicode-xid"
@@ -3502,6 +5354,7 @@ version = "1.21.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb"
 dependencies = [
+ "atomic",
  "getrandom 0.4.1",
  "js-sys",
  "serde_core",
@@ -3514,6 +5367,12 @@ version = "0.9.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
 
+[[package]]
+name = "vsimd"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64"
+
 [[package]]
 name = "vte"
 version = "0.14.1"
@@ -3523,6 +5382,15 @@ dependencies = [
  "memchr",
 ]
 
+[[package]]
+name = "vtparse"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0"
+dependencies = [
+ "utf8parse",
+]
+
 [[package]]
 name = "wait-timeout"
 version = "0.2.1"
@@ -3611,7 +5479,7 @@ dependencies = [
  "bumpalo",
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.117",
  "wasm-bindgen-shared",
 ]
 
@@ -3665,7 +5533,7 @@ version = "0.244.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
 dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
  "hashbrown 0.15.5",
  "indexmap 2.13.0",
  "semver",
@@ -3700,6 +5568,78 @@ dependencies = [
  "rustls-pki-types",
 ]
 
+[[package]]
+name = "wezterm-bidi"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec"
+dependencies = [
+ "log",
+ "wezterm-dynamic",
+]
+
+[[package]]
+name = "wezterm-blob-leases"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7"
+dependencies = [
+ "getrandom 0.3.4",
+ "mac_address",
+ "sha2",
+ "thiserror 1.0.69",
+ "uuid",
+]
+
+[[package]]
+name = "wezterm-color-types"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296"
+dependencies = [
+ "csscolorparser",
+ "deltae",
+ "lazy_static",
+ "wezterm-dynamic",
+]
+
+[[package]]
+name = "wezterm-dynamic"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac"
+dependencies = [
+ "log",
+ "ordered-float",
+ "strsim",
+ "thiserror 1.0.69",
+ "wezterm-dynamic-derive",
+]
+
+[[package]]
+name = "wezterm-dynamic-derive"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "wezterm-input-types"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e"
+dependencies = [
+ "bitflags 1.3.2",
+ "euclid",
+ "lazy_static",
+ "serde",
+ "wezterm-dynamic",
+]
+
 [[package]]
 name = "winapi"
 version = "0.3.9"
@@ -3722,7 +5662,7 @@ version = "0.1.11"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
 dependencies = [
- "windows-sys 0.48.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -3752,7 +5692,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -3763,7 +5703,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -4060,7 +6000,7 @@ dependencies = [
  "heck",
  "indexmap 2.13.0",
  "prettyplease",
- "syn",
+ "syn 2.0.117",
  "wasm-metadata",
  "wit-bindgen-core",
  "wit-component",
@@ -4076,7 +6016,7 @@ dependencies = [
  "prettyplease",
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.117",
  "wit-bindgen-core",
  "wit-bindgen-rust",
 ]
@@ -4088,7 +6028,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
 dependencies = [
  "anyhow",
- "bitflags",
+ "bitflags 2.13.0",
  "indexmap 2.13.0",
  "log",
  "serde",
@@ -4143,7 +6083,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.117",
  "synstructure",
 ]
 
@@ -4164,7 +6104,7 @@ checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -4184,7 +6124,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.117",
  "synstructure",
 ]
 
@@ -4224,7 +6164,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.117",
 ]
 
 [[package]]
diff --git a/Cargo.toml b/Cargo.toml
index 9a3df58..c26ee3a 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -23,7 +23,7 @@ clap = { version = "4.5.20", features = ["derive", "env"] }
 crossterm = "0.28.1"
 futures-util = "0.3.31"
 indicatif = "0.17.8"
-ratatui = "0.29.0"
+ratatui = "0.30.2"
 reqwest = { version = "0.12.7", default-features = false, features = ["json", "rustls-tls-native-roots"] }
 serde = { version = "1.0.210", features = ["derive"] }
 serde_json = "1.0.128"
@@ -51,6 +51,23 @@ glob = "0.3"
 flate2 = "1.1.2"
 tempfile = "3"
 uuid = { version = "1.21.0", features = ["v4"] }
+oxc_allocator = "0.81"
+oxc_codegen = "0.81"
+oxc_parser = "0.81"
+oxc_semantic = "0.81"
+oxc_span = "0.81"
+oxc_transformer = "0.81"
+swc_atoms = "9.0.1"
+swc_bundler = "49.0.0"
+swc_common = "21.0.2"
+swc_ecma_ast = "23.0.1"
+swc_ecma_codegen = "26.0.1"
+swc_ecma_loader = { version = "22.0.0", features = ["node"] }
+swc_ecma_parser = "39.1.0"
+swc_ecma_transforms_base = "42.0.1"
+swc_ecma_transforms_react = "47.0.0"
+swc_ecma_transforms_typescript = "47.0.0"
+swc_ecma_visit = "22.0.0"
 
 [profile.dist]
 inherits = "release"
diff --git a/build.rs b/build.rs
index 06392f8..757aecd 100644
--- a/build.rs
+++ b/build.rs
@@ -1,6 +1,23 @@
 use std::env;
+use std::fs;
+use std::path::PathBuf;
 use std::process::Command;
 
+const PREVIEW_ASSETS: &[(&str, &str)] = &[
+    (
+        "node_modules/react/umd/react.development.js",
+        "views-preview-react.js",
+    ),
+    (
+        "node_modules/react-dom/umd/react-dom.development.js",
+        "views-preview-react-dom.js",
+    ),
+    (
+        "node_modules/@tailwindcss/browser/dist/index.global.js",
+        "views-preview-tailwindcss-browser.js",
+    ),
+];
+
 fn non_empty_env(name: &str) -> Option {
     env::var(name)
         .ok()
@@ -46,7 +63,36 @@ fn compute_default_version() -> String {
     }
 }
 
+fn copy_preview_assets() {
+    let manifest_dir =
+        PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set by cargo"));
+    let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR is set by cargo"));
+
+    println!("cargo:rerun-if-changed=package.json");
+    println!("cargo:rerun-if-changed=pnpm-lock.yaml");
+    for (source, output) in PREVIEW_ASSETS {
+        let source_path = manifest_dir.join(source);
+        println!("cargo:rerun-if-changed={}", source_path.display());
+        if !source_path.is_file() {
+            panic!(
+                "missing preview asset {}; run `pnpm install --ignore-scripts` before building bt",
+                source_path.display()
+            );
+        }
+        let output_path = out_dir.join(output);
+        fs::copy(&source_path, &output_path).unwrap_or_else(|err| {
+            panic!(
+                "failed to copy preview asset {} to {}: {err}",
+                source_path.display(),
+                output_path.display()
+            )
+        });
+    }
+}
+
 fn main() {
+    copy_preview_assets();
+
     let version = non_empty_env("BT_VERSION_STRING").unwrap_or_else(compute_default_version);
     println!("cargo:rustc-env=BT_VERSION_STRING={version}");
 
diff --git a/package.json b/package.json
index ab4fab1..144ceef 100644
--- a/package.json
+++ b/package.json
@@ -3,7 +3,10 @@
   "private": true,
   "packageManager": "pnpm@10.28.2",
   "devDependencies": {
+    "@tailwindcss/browser": "4.3.2",
     "@types/node": "22.15.0",
+    "react": "18.3.1",
+    "react-dom": "18.3.1",
     "typescript": "5.4.4"
   }
 }
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5b20e35..663eef9 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1,45 +1,92 @@
-lockfileVersion: "9.0"
+lockfileVersion: '9.0'
 
 settings:
   autoInstallPeers: true
   excludeLinksFromLockfile: false
 
 importers:
+
   .:
     devDependencies:
-      "@types/node":
+      '@tailwindcss/browser':
+        specifier: 4.3.2
+        version: 4.3.2
+      '@types/node':
         specifier: 22.15.0
         version: 22.15.0
+      react:
+        specifier: 18.3.1
+        version: 18.3.1
+      react-dom:
+        specifier: 18.3.1
+        version: 18.3.1(react@18.3.1)
       typescript:
         specifier: 5.4.4
         version: 5.4.4
 
 packages:
-  "@types/node@22.15.0":
-    resolution:
-      {
-        integrity: sha512-99S8dWD2DkeE6PBaEDw+In3aar7hdoBvjyJMR6vaKBTzpvR0P00ClzJMOoVrj9D2+Sy/YCwACYHnBTpMhg1UCA==,
-      }
+
+  '@tailwindcss/browser@4.3.2':
+    resolution: {integrity: sha512-R6hewP9HHwLAY88bFkgAYFIQMRGi9bqx6sR1anI3IThTJtwhPlAm4aHIzDKrnJ8Ct4M3d5A9PCsah8Z5+EjLiQ==}
+
+  '@types/node@22.15.0':
+    resolution: {integrity: sha512-99S8dWD2DkeE6PBaEDw+In3aar7hdoBvjyJMR6vaKBTzpvR0P00ClzJMOoVrj9D2+Sy/YCwACYHnBTpMhg1UCA==}
+
+  js-tokens@4.0.0:
+    resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
+
+  loose-envify@1.4.0:
+    resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
+    hasBin: true
+
+  react-dom@18.3.1:
+    resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
+    peerDependencies:
+      react: ^18.3.1
+
+  react@18.3.1:
+    resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
+    engines: {node: '>=0.10.0'}
+
+  scheduler@0.23.2:
+    resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
 
   typescript@5.4.4:
-    resolution:
-      {
-        integrity: sha512-dGE2Vv8cpVvw28v8HCPqyb08EzbBURxDpuhJvTrusShUfGnhHBafDsLdS1EhhxyL6BJQE+2cT3dDPAv+MQ6oLw==,
-      }
-    engines: { node: ">=14.17" }
+    resolution: {integrity: sha512-dGE2Vv8cpVvw28v8HCPqyb08EzbBURxDpuhJvTrusShUfGnhHBafDsLdS1EhhxyL6BJQE+2cT3dDPAv+MQ6oLw==}
+    engines: {node: '>=14.17'}
     hasBin: true
 
   undici-types@6.21.0:
-    resolution:
-      {
-        integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==,
-      }
+    resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
 
 snapshots:
-  "@types/node@22.15.0":
+
+  '@tailwindcss/browser@4.3.2': {}
+
+  '@types/node@22.15.0':
     dependencies:
       undici-types: 6.21.0
 
+  js-tokens@4.0.0: {}
+
+  loose-envify@1.4.0:
+    dependencies:
+      js-tokens: 4.0.0
+
+  react-dom@18.3.1(react@18.3.1):
+    dependencies:
+      loose-envify: 1.4.0
+      react: 18.3.1
+      scheduler: 0.23.2
+
+  react@18.3.1:
+    dependencies:
+      loose-envify: 1.4.0
+
+  scheduler@0.23.2:
+    dependencies:
+      loose-envify: 1.4.0
+
   typescript@5.4.4: {}
 
   undici-types@6.21.0: {}
diff --git a/scripts/views-preview.html b/scripts/views-preview.html
new file mode 100644
index 0000000..c1baec2
--- /dev/null
+++ b/scripts/views-preview.html
@@ -0,0 +1,210 @@
+
+
+
+  
+  
+  __HTML_TITLE__
+  
+  
+  
+  
+  
+
+
+  
+ + + diff --git a/scripts/views-runner.mjs b/scripts/views-runner.mjs new file mode 100644 index 0000000..855cd31 --- /dev/null +++ b/scripts/views-runner.mjs @@ -0,0 +1,189 @@ +import fs from "node:fs"; +import { pathToFileURL } from "node:url"; + +function isObject(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function installDiscoveryReactStub() { + if (globalThis.React) { + return; + } + + const createElement = (...args) => ({ + type: args[0], + props: args[1] ?? {}, + children: args.slice(2), + }); + globalThis.React = { + Children: {}, + Component: class {}, + Fragment: Symbol.for("react.fragment"), + Profiler: Symbol.for("react.profiler"), + PureComponent: class {}, + StrictMode: Symbol.for("react.strict_mode"), + Suspense: Symbol.for("react.suspense"), + cloneElement: createElement, + createContext: () => ({}), + createElement, + createRef: () => ({ current: null }), + forwardRef: (component) => component, + isValidElement: (value) => typeof value === "object" && value !== null, + lazy: (loader) => loader, + memo: (component) => component, + startTransition: (callback) => callback(), + useCallback: (callback) => callback, + useContext: () => undefined, + useDebugValue: () => undefined, + useDeferredValue: (value) => value, + useEffect: () => undefined, + useId: () => "bt-view-id", + useImperativeHandle: () => undefined, + useInsertionEffect: () => undefined, + useLayoutEffect: () => undefined, + useMemo: (factory) => factory(), + useReducer: (_reducer, initialValue) => [initialValue, () => undefined], + useRef: (current) => ({ current }), + useState: (initialValue) => [initialValue, () => undefined], + useSyncExternalStore: () => undefined, + useTransition: () => [false, () => undefined], + }; +} + +function defaultExportFromModule(loaded) { + if ( + !isObject(loaded) || + !Object.prototype.hasOwnProperty.call(loaded, "default") + ) { + return undefined; + } + const value = loaded.default; + if ( + isObject(value) && + Object.prototype.hasOwnProperty.call(value, "default") + ) { + return value.default; + } + return value; +} + +function validateCustomView(value, sourceFile) { + if (!isObject(value)) { + throw new Error( + `${sourceFile} must default-export customTraceView(...) or customDatasetView(...)`, + ); + } + if (value.kind !== "trace" && value.kind !== "dataset") { + throw new Error( + `${sourceFile} default export must be a trace or dataset custom view`, + ); + } + if (typeof value.name !== "string" || !value.name.trim()) { + throw new Error(`${sourceFile} custom view name is required`); + } + if (typeof value.slug !== "string" || !value.slug.trim()) { + throw new Error(`${sourceFile} custom view '${value.name}' slug is required`); + } + if (typeof value.component !== "function") { + throw new Error( + `${sourceFile} custom view '${value.slug}' component must be a function`, + ); + } +} + +function projectFields(project) { + if (typeof project === "string" && project.trim()) { + return { project_name: project }; + } + if (!isObject(project)) { + return {}; + } + return { + ...(typeof project.id === "string" && project.id.trim() + ? { project_id: project.id } + : {}), + ...(typeof project.name === "string" && project.name.trim() + ? { project_name: project.name } + : {}), + }; +} + +function datasetFields(view) { + if (view.kind !== "dataset" || !isObject(view.dataset)) { + return {}; + } + return { + ...(typeof view.dataset.id === "string" && view.dataset.id.trim() + ? { dataset_id: view.dataset.id } + : {}), + ...(typeof view.dataset.name === "string" && view.dataset.name.trim() + ? { dataset_name: view.dataset.name } + : {}), + }; +} + +async function buildManifest(inputPath) { + const input = JSON.parse(fs.readFileSync(inputPath, "utf8")); + if (!Array.isArray(input.files) || input.files.length === 0) { + throw new Error("views metadata runner requires at least one bundled view"); + } + + installDiscoveryReactStub(); + const manifest = { + runtime_context: { + runtime: "browser", + version: "latest", + }, + files: [], + }; + + for (const file of input.files) { + if (!isObject(file)) { + throw new Error("views metadata input file entries must be objects"); + } + const { source_file: sourceFile, bundle_file: bundleFile } = file; + if (typeof sourceFile !== "string" || typeof bundleFile !== "string") { + throw new Error("views metadata input entries require source_file and bundle_file"); + } + const loaded = await import( + `${pathToFileURL(bundleFile).href}?bt_view_nonce=${Date.now()}` + ); + const view = defaultExportFromModule(loaded); + validateCustomView(view, sourceFile); + manifest.files.push({ + source_file: sourceFile, + dependencies: [], + entries: [ + { + kind: "view", + view_type: view.kind, + name: view.name, + slug: view.slug, + code: "", + ...projectFields(view.project), + ...datasetFields(view), + }, + ], + }); + } + + return manifest; +} + +async function main() { + const inputPath = process.argv[2]; + if (!inputPath) { + throw new Error("views metadata runner requires an input path argument"); + } + const manifest = await buildManifest(inputPath); + process.stdout.write(`${JSON.stringify(manifest)}\n`); +} + +main().catch((error) => { + const message = + error instanceof Error + ? error.message + : `failed to build custom view metadata: ${String(error)}`; + process.stderr.write(`${message}\n`); + process.exitCode = 1; +}); diff --git a/scripts/views-runner.ts b/scripts/views-runner.ts deleted file mode 100644 index 46fa42e..0000000 --- a/scripts/views-runner.ts +++ /dev/null @@ -1,530 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { createRequire } from "node:module"; -import { pathToFileURL } from "node:url"; - -type EsbuildModule = { - build: (options: Record) => Promise; -}; - -type EsbuildBuildResult = { - metafile?: { - inputs?: Record; - }; -}; - -type CustomViewDefinition = { - kind: "trace" | "dataset"; - name: string; - slug: string; - component: unknown; - project?: string | { id?: string; name?: string }; - dataset?: { id?: string; name?: string }; -}; - -type ManifestEntry = { - kind: "view"; - view_type: "trace" | "dataset"; - name: string; - slug: string; - code: string; - project_id?: string; - project_name?: string; - dataset_id?: string; - dataset_name?: string; -}; - -type Manifest = { - runtime_context: { - runtime: "browser"; - version: "latest"; - }; - files: Array<{ - source_file: string; - dependencies: string[]; - entries: ManifestEntry[]; - }>; -}; - -const __filename = path.resolve(process.argv[1] || "views-runner.ts"); -const __dirname = path.dirname(__filename); - -function isObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function normalizeEsbuildModule(value: unknown): EsbuildModule | null { - if (isObject(value) && typeof value.build === "function") { - return value as EsbuildModule; - } - if ( - isObject(value) && - isObject(value.default) && - typeof value.default.build === "function" - ) { - return value.default as EsbuildModule; - } - return null; -} - -async function loadEsbuild(sourceFiles: string[]): Promise { - const candidates = [ - ...sourceFiles.map((file) => path.resolve(file)), - path.join(process.cwd(), "package.json"), - path.join(__dirname, "package.json"), - ]; - - for (const candidate of candidates) { - try { - const requireFrom = createRequire(pathToFileURL(candidate).href); - const resolved = requireFrom.resolve("esbuild"); - const loaded = requireFrom(resolved); - const normalized = normalizeEsbuildModule(loaded); - if (normalized) { - return normalized; - } - } catch { - // Try the next search root. - } - } - - try { - const esbuildSpecifier = "esbuild"; - const loaded = await import(esbuildSpecifier); - const normalized = normalizeEsbuildModule(loaded); - if (normalized) { - return normalized; - } - } catch { - // handled below - } - - throw new Error( - "failed to load esbuild for custom view bundling; install esbuild in your project", - ); -} - -function sdkPath(): string { - return process.env.BT_VIEWS_SDK_PATH || path.join(__dirname, "views-sdk.ts"); -} - -function reactModuleSource(): string { - return ` -const ReactValue = globalThis.React || React; -export default ReactValue; -export const Children = ReactValue.Children; -export const Component = ReactValue.Component; -export const Fragment = ReactValue.Fragment; -export const Profiler = ReactValue.Profiler; -export const PureComponent = ReactValue.PureComponent; -export const StrictMode = ReactValue.StrictMode; -export const Suspense = ReactValue.Suspense; -export const cloneElement = ReactValue.cloneElement; -export const createContext = ReactValue.createContext; -export const createElement = ReactValue.createElement; -export const createRef = ReactValue.createRef; -export const forwardRef = ReactValue.forwardRef; -export const isValidElement = ReactValue.isValidElement; -export const lazy = ReactValue.lazy; -export const memo = ReactValue.memo; -export const startTransition = ReactValue.startTransition; -export const useCallback = ReactValue.useCallback; -export const useContext = ReactValue.useContext; -export const useDebugValue = ReactValue.useDebugValue; -export const useDeferredValue = ReactValue.useDeferredValue; -export const useEffect = ReactValue.useEffect; -export const useId = ReactValue.useId; -export const useImperativeHandle = ReactValue.useImperativeHandle; -export const useInsertionEffect = ReactValue.useInsertionEffect; -export const useLayoutEffect = ReactValue.useLayoutEffect; -export const useMemo = ReactValue.useMemo; -export const useReducer = ReactValue.useReducer; -export const useRef = ReactValue.useRef; -export const useState = ReactValue.useState; -export const useSyncExternalStore = ReactValue.useSyncExternalStore; -export const useTransition = ReactValue.useTransition; -`; -} - -function jsxRuntimeSource(): string { - return ` -const ReactValue = globalThis.React || React; -export const Fragment = ReactValue.Fragment; -export const jsx = ReactValue.createElement; -export const jsxs = ReactValue.createElement; -export const jsxDEV = ReactValue.createElement; -`; -} - -function installDiscoveryReactStub(): void { - const globalWithReact = globalThis as { - React?: Record; - }; - if (globalWithReact.React) { - return; - } - const createElement = (...args: unknown[]) => ({ - type: args[0], - props: args[1] ?? {}, - children: args.slice(2), - }); - globalWithReact.React = { - Children: {}, - Component: class {}, - Fragment: Symbol.for("react.fragment"), - Profiler: Symbol.for("react.profiler"), - PureComponent: class {}, - StrictMode: Symbol.for("react.strict_mode"), - Suspense: Symbol.for("react.suspense"), - cloneElement: createElement, - createContext: () => ({}), - createElement, - createRef: () => ({ current: null }), - forwardRef: (component: unknown) => component, - isValidElement: (value: unknown) => - typeof value === "object" && value !== null, - lazy: (loader: unknown) => loader, - memo: (component: unknown) => component, - startTransition: (callback: () => void) => callback(), - useCallback: (callback: unknown) => callback, - useContext: () => undefined, - useDebugValue: () => undefined, - useDeferredValue: (value: unknown) => value, - useEffect: () => undefined, - useId: () => "bt-view-preview-id", - useImperativeHandle: () => undefined, - useInsertionEffect: () => undefined, - useLayoutEffect: () => undefined, - useMemo: (factory: () => unknown) => factory(), - useReducer: (_reducer: unknown, initialValue: unknown) => [ - initialValue, - () => undefined, - ], - useRef: (current: unknown) => ({ current }), - useState: (initialValue: unknown) => [initialValue, () => undefined], - useSyncExternalStore: () => undefined, - useTransition: () => [false, () => undefined], - }; -} - -function braintrustViewPlugin() { - const helperPath = sdkPath(); - return { - name: "braintrust-custom-view-local-sdk", - setup(build: { - onResolve: ( - options: { filter: RegExp }, - callback: (args: { path: string }) => unknown, - ) => void; - onLoad: ( - options: { filter: RegExp; namespace?: string }, - callback: (args: { path: string }) => unknown, - ) => void; - }) { - build.onResolve({ filter: /^braintrust\/custom-views$/ }, () => ({ - path: helperPath, - })); - build.onResolve({ filter: /^@braintrust\/local\/custom-views$/ }, () => ({ - path: helperPath, - })); - build.onResolve({ filter: /^react$/ }, () => ({ - path: "react", - namespace: "bt-view-react", - })); - build.onResolve({ filter: /^react\/jsx-runtime$/ }, () => ({ - path: "react/jsx-runtime", - namespace: "bt-view-react", - })); - build.onResolve({ filter: /^react\/jsx-dev-runtime$/ }, () => ({ - path: "react/jsx-dev-runtime", - namespace: "bt-view-react", - })); - build.onLoad( - { filter: /^react$/, namespace: "bt-view-react" }, - () => ({ - contents: reactModuleSource(), - loader: "js", - }), - ); - build.onLoad( - { filter: /^react\/jsx-(dev-)?runtime$/, namespace: "bt-view-react" }, - () => ({ - contents: jsxRuntimeSource(), - loader: "js", - }), - ); - }, - }; -} - -function projectFields( - project: CustomViewDefinition["project"], -): Pick { - if (project === undefined) { - return {}; - } - if (typeof project === "string") { - return { project_name: project }; - } - if (typeof project.id === "string" && project.id.trim()) { - return { project_id: project.id }; - } - if (typeof project.name === "string" && project.name.trim()) { - return { project_name: project.name }; - } - throw new Error("custom view project must be a string, { id }, or { name }"); -} - -function datasetFields( - view: CustomViewDefinition, -): Pick { - if (view.kind !== "dataset") { - return {}; - } - const dataset = view.dataset; - if (!dataset) { - throw new Error(`dataset custom view '${view.slug}' requires dataset`); - } - if (typeof dataset.id === "string" && dataset.id.trim()) { - return { dataset_id: dataset.id }; - } - if (typeof dataset.name === "string" && dataset.name.trim()) { - return { dataset_name: dataset.name }; - } - throw new Error( - `dataset custom view '${view.slug}' dataset must be { id } or { name }`, - ); -} - -function validateCustomView( - value: unknown, - sourceFile: string, -): asserts value is CustomViewDefinition { - if (!isObject(value)) { - throw new Error( - `${sourceFile} must default-export customTraceView(...) or customDatasetView(...)`, - ); - } - if (value.kind !== "trace" && value.kind !== "dataset") { - throw new Error( - `${sourceFile} default export must be a trace or dataset custom view`, - ); - } - if (typeof value.name !== "string" || !value.name.trim()) { - throw new Error(`${sourceFile} custom view name is required`); - } - if (typeof value.slug !== "string" || !value.slug.trim()) { - throw new Error(`${sourceFile} custom view '${value.name}' slug is required`); - } - if (typeof value.component !== "function") { - throw new Error( - `${sourceFile} custom view '${value.slug}' component must be a function`, - ); - } -} - -function defaultExportFromModule(loaded: unknown): unknown { - if ( - !isObject(loaded) || - !Object.prototype.hasOwnProperty.call(loaded, "default") - ) { - return undefined; - } - const value = loaded.default; - if ( - isObject(value) && - Object.prototype.hasOwnProperty.call(value, "default") - ) { - return value.default; - } - return value; -} - -async function bundleForDiscovery( - esbuild: EsbuildModule, - sourceFile: string, - outputFile: string, -): Promise { - return await esbuild.build({ - entryPoints: [sourceFile], - outfile: outputFile, - bundle: true, - platform: "node", - target: "node18", - format: "cjs", - write: true, - metafile: true, - plugins: [braintrustViewPlugin()], - }); -} - -async function collectView( - esbuild: EsbuildModule, - sourceFile: string, - tempDir: string, -): Promise<{ view: CustomViewDefinition; dependencies: string[] }> { - installDiscoveryReactStub(); - const outputFile = path.join( - tempDir, - `${path.basename(sourceFile).replace(/[^\w.-]/g, "_")}.collector.cjs`, - ); - const buildResult = await bundleForDiscovery( - esbuild, - sourceFile, - outputFile, - ); - const loaded = await import( - `${pathToFileURL(outputFile).href}?bt_view_nonce=${Date.now()}` - ); - const view = defaultExportFromModule(loaded); - validateCustomView(view, sourceFile); - return { view, dependencies: dependencyFiles(buildResult, tempDir) }; -} - -function virtualEntrySource(sourceFile: string): string { - return ` -import view from ${JSON.stringify(sourceFile)}; -if (!view || (view.kind !== "trace" && view.kind !== "dataset") || !view.component) { - throw new Error("Custom view file must default-export customTraceView(...) or customDatasetView(...)"); -} -export default view.component; -`; -} - -async function bundleBrowserCode( - esbuild: EsbuildModule, - sourceFile: string, - view: CustomViewDefinition, - tempDir: string, -): Promise<{ code: string; dependencies: string[] }> { - const entryFile = path.join( - tempDir, - `${view.slug?.replace(/[^\w.-]/g, "_") || "view"}.entry.ts`, - ); - const outputFile = path.join( - tempDir, - `${view.slug?.replace(/[^\w.-]/g, "_") || "view"}.browser.js`, - ); - fs.writeFileSync(entryFile, virtualEntrySource(sourceFile), "utf8"); - - const buildResult = await esbuild.build({ - entryPoints: [entryFile], - outfile: outputFile, - bundle: true, - platform: "browser", - target: "es2019", - format: "iife", - globalName: "__BraintrustCustomView", - write: true, - metafile: true, - treeShaking: true, - jsxFactory: "React.createElement", - jsxFragment: "React.Fragment", - footer: { - js: "module.exports = __BraintrustCustomView;", - }, - plugins: [braintrustViewPlugin()], - }); - - return { - code: fs.readFileSync(outputFile, "utf8"), - dependencies: dependencyFiles(buildResult, tempDir), - }; -} - -function dependencyFiles( - buildResult: EsbuildBuildResult, - tempDir: string, -): string[] { - const inputs = Object.keys(buildResult.metafile?.inputs ?? {}); - const dependencies = new Set(); - const tempRoot = fs.realpathSync.native(tempDir); - for (const input of inputs) { - if (input.includes(":") && !path.isAbsolute(input)) { - continue; - } - let file = path.isAbsolute(input) - ? path.normalize(input) - : path.resolve(process.cwd(), input); - if (fs.existsSync(file)) { - file = fs.realpathSync.native(file); - } - const relativeToTemp = path.relative(tempRoot, file); - if ( - relativeToTemp === "" || - (!relativeToTemp.startsWith("..") && !path.isAbsolute(relativeToTemp)) - ) { - continue; - } - dependencies.add(file); - } - return [...dependencies].sort(); -} - -async function buildManifest(files: string[]): Promise { - const sourceFiles = files.map((file) => path.resolve(file)); - const esbuild = await loadEsbuild(sourceFiles); - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "bt-views-")); - - try { - const manifest: Manifest = { - runtime_context: { - runtime: "browser", - version: "latest", - }, - files: [], - }; - - for (const sourceFile of sourceFiles) { - if (!fs.existsSync(sourceFile)) { - throw new Error(`custom view file not found: ${sourceFile}`); - } - const collected = await collectView(esbuild, sourceFile, tempDir); - const bundled = await bundleBrowserCode( - esbuild, - sourceFile, - collected.view, - tempDir, - ); - const dependencies = [ - ...new Set([...collected.dependencies, ...bundled.dependencies]), - ].sort(); - const entries: ManifestEntry[] = [ - { - kind: "view", - view_type: collected.view.kind, - name: collected.view.name, - slug: collected.view.slug, - code: bundled.code, - ...projectFields(collected.view.project), - ...datasetFields(collected.view), - }, - ]; - manifest.files.push({ source_file: sourceFile, dependencies, entries }); - } - - return manifest; - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -} - -async function main(): Promise { - const files = process.argv.slice(2); - if (files.length === 0) { - throw new Error("views-runner requires at least one view file"); - } - - const manifest = await buildManifest(files); - process.stdout.write(`${JSON.stringify(manifest)}\n`); -} - -main().catch((error: unknown) => { - const message = - error instanceof Error - ? error.message - : `failed to build custom view manifest: ${String(error)}`; - process.stderr.write(`${message}\n`); - process.exitCode = 1; -}); diff --git a/src/views.rs b/src/views.rs index 041a809..ccbc9dd 100644 --- a/src/views.rs +++ b/src/views.rs @@ -1,17 +1,41 @@ -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::fmt::Write as _; +use std::io::Write; use std::net::TcpListener; use std::path::{Path, PathBuf}; -use std::process::Command; -use std::sync::Mutex; +use std::process::{Command, Stdio}; +use std::sync::{Arc, Mutex}; use std::time::UNIX_EPOCH; use actix_web::{web, App, HttpResponse, HttpServer}; use anyhow::{anyhow, bail, Context, Result}; use clap::{builder::BoolishValueParser, Args, Subcommand}; +use oxc_allocator::Allocator; +use oxc_codegen::{Codegen, CodegenOptions}; +use oxc_parser::Parser; +use oxc_semantic::SemanticBuilder; +use oxc_span::SourceType; +use oxc_transformer::{TransformOptions, Transformer}; use reqwest::Url; use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; +use swc_bundler::{ + Bundle, BundleKind, Bundler, Config as SwcBundlerConfig, Hook, Load, ModuleData, ModuleRecord, + ModuleType, +}; +use swc_common::{comments::NoopComments, sync::Lrc, FileName, Globals, Mark, SourceMap, Span}; +use swc_ecma_ast::{EsVersion, KeyValueProp, Program}; +use swc_ecma_codegen::to_code_default; +use swc_ecma_loader::{ + resolve::{Resolution, Resolve}, + resolvers::node::NodeModulesResolver, + TargetEnv as SwcTargetEnv, +}; +use swc_ecma_parser::{parse_file_as_module, EsSyntax, Syntax, TsSyntax}; +use swc_ecma_transforms_base::helpers::Helpers; +use swc_ecma_transforms_base::{fixer::fixer, resolver}; +use swc_ecma_transforms_react::{react, Options as ReactOptions, Runtime as ReactRuntime}; +use swc_ecma_transforms_typescript::strip as strip_typescript; use urlencoding::encode; use crate::args::BaseArgs; @@ -19,16 +43,22 @@ use crate::auth; use crate::datasets::api as datasets_api; use crate::functions::{self, api as functions_api, IfExistsMode}; use crate::http::ApiClient; -use crate::js_runner; use crate::project_context::{resolve_project_optional, resolve_required_project}; use crate::projects::api::{get_project_by_name, list_projects, Project}; use crate::ui::{self, with_spinner}; use crate::utils::{app_project_url, app_project_url_with_encoded_path}; -const VIEWS_JS_RUNNER_FILE: &str = "views-runner.ts"; -const VIEWS_JS_SDK_FILE: &str = "views-sdk.ts"; -const VIEWS_JS_RUNNER_SOURCE: &str = include_str!("../scripts/views-runner.ts"); +const VIEWS_JS_RUNNER_SOURCE: &str = include_str!("../scripts/views-runner.mjs"); const VIEWS_JS_SDK_SOURCE: &str = include_str!("../scripts/views-sdk.ts"); +const VIEWS_PREVIEW_HTML_TEMPLATE: &str = include_str!("../scripts/views-preview.html"); +const VIEWS_PREVIEW_REACT_SOURCE: &str = + include_str!(concat!(env!("OUT_DIR"), "/views-preview-react.js")); +const VIEWS_PREVIEW_REACT_DOM_SOURCE: &str = + include_str!(concat!(env!("OUT_DIR"), "/views-preview-react-dom.js")); +const VIEWS_PREVIEW_TAILWIND_SOURCE: &str = include_str!(concat!( + env!("OUT_DIR"), + "/views-preview-tailwindcss-browser.js" +)); const DEFAULT_TRACE_PREVIEW_LIMIT: usize = 1000; const DEFAULT_CUSTOM_VIEWS_DIR: &str = "braintrust-custom-views"; const CUSTOM_VIEW_TSCONFIG_FILE: &str = "tsconfig.json"; @@ -285,6 +315,17 @@ struct ViewsManifest { files: Vec, } +#[derive(Debug, Serialize)] +struct ViewsDiscoveryInput { + files: Vec, +} + +#[derive(Debug, Serialize)] +struct ViewsDiscoveryInputFile { + source_file: String, + bundle_file: String, +} + #[derive(Debug, Deserialize)] struct ViewsRuntimeContext { runtime: String, @@ -294,6 +335,7 @@ struct ViewsRuntimeContext { #[derive(Debug, Deserialize)] struct ViewsManifestFile { source_file: String, + #[allow(dead_code)] #[serde(default)] dependencies: Vec, #[serde(default)] @@ -360,18 +402,19 @@ struct BtqlResponse { struct PreviewServerState { client: ApiClient, - project_id: Option, - data: Value, + project: Option, + target: PreviewTarget, source: PreviewSource, title: String, dependency_paths: Mutex>, + trace_project_id: Mutex>, + trace_data: Mutex>, } struct PreviewContext { client: ApiClient, project: Option, - entry: ViewManifestEntry, - dependency_paths: Vec, + source: PreviewSource, } struct TracePreviewData { @@ -382,8 +425,15 @@ struct TracePreviewData { #[derive(Clone)] struct PreviewSource { path: PathBuf, + root: PathBuf, view: Option, - view_type: Option, + view_type: ViewType, +} + +#[derive(Clone)] +enum PreviewTarget { + Trace(TracePreviewTargetArgs), + Dataset(DatasetPreviewTargetArgs), } #[derive(Debug, Deserialize)] @@ -393,6 +443,17 @@ struct SpanFieldsRequest { fields: Option>, } +#[derive(Debug, Deserialize)] +struct PreviewDataRequest { + view_type: ViewType, + name: String, + slug: String, + project_id: Option, + project_name: Option, + dataset_id: Option, + dataset_name: Option, +} + pub async fn run(base: BaseArgs, args: ViewsArgs) -> Result<()> { match args.command { ViewsCommands::Push(push_args) => push(base, push_args).await, @@ -935,41 +996,30 @@ async fn preview_trace(base: BaseArgs, args: TraceViewPreviewArgs) -> Result<()> let context = resolve_preview_context( &base, &args.common, - Some(ViewType::Trace), + ViewType::Trace, args.target.url.as_deref(), ) .await?; - let trace_data = - build_trace_preview_data(&context.client, context.project.as_ref(), &args.target).await?; serve_preview( base, &args.common, context.client, - Some(trace_data.project_id), - context.entry, - context.dependency_paths, - trace_data.data, + context.project, + PreviewTarget::Trace(args.target), + context.source, ) .await } async fn preview_dataset(base: BaseArgs, args: DatasetViewPreviewArgs) -> Result<()> { - let context = - resolve_preview_context(&base, &args.common, Some(ViewType::Dataset), None).await?; - let project = context - .project - .as_ref() - .ok_or_else(|| anyhow!("dataset preview requires a project"))?; - let preview_data = - build_dataset_preview_data(&context.client, project, &context.entry, &args.target).await?; + let context = resolve_preview_context(&base, &args.common, ViewType::Dataset, None).await?; serve_preview( base, &args.common, context.client, - None, - context.entry, - context.dependency_paths, - preview_data, + context.project, + PreviewTarget::Dataset(args.target), + context.source, ) .await } @@ -977,47 +1027,24 @@ async fn preview_dataset(base: BaseArgs, args: DatasetViewPreviewArgs) -> Result async fn resolve_preview_context( base: &BaseArgs, args: &PreviewCommonArgs, - view_type: Option, + view_type: ViewType, trace_url: Option<&str>, ) -> Result { - if !args.path.exists() { - bail!("custom view file not found: {}", args.path.display()); - } - if !args.path.is_file() { - bail!( - "custom view preview requires a single view file; got {}", - args.path.display() - ); - } - if !is_view_file(&args.path) { - bail!("custom view preview path must match {VIEW_FILE_PATTERN_HELP}"); - } - let files = vec![args.path.clone()]; - let manifest = run_views_runner(&files)?; - validate_manifest_runtime(&manifest)?; - let entry = select_preview_entry(&manifest, args.view.as_deref(), view_type)?.clone(); - let dependency_paths = preview_dependency_paths(&manifest); + let source = prepare_preview_source(&args.path, args.view.clone(), view_type)?; let auth_ctx = auth::login_read_only(&base).await?; let client = ApiClient::new(&auth_ctx)?; - let project = if trace_url_supplies_project(entry.view_type, trace_url) { + let project = if trace_url_supplies_project(view_type, trace_url) { None - } else if entry.view_type == ViewType::Trace && trace_url.is_some() { - match resolve_project_optional(&base, &client, true).await? { - Some(default_project) => { - Some(resolve_project_for_entry(&client, &default_project, &entry).await?) - } - None => None, - } + } else if view_type == ViewType::Trace && trace_url.is_some() { + resolve_project_optional(&base, &client, true).await? } else { - let default_project = resolve_required_project(&base, &client, true).await?; - Some(resolve_project_for_entry(&client, &default_project, &entry).await?) + Some(resolve_required_project(&base, &client, true).await?) }; Ok(PreviewContext { client, project, - entry, - dependency_paths, + source, }) } @@ -1025,10 +1052,9 @@ async fn serve_preview( base: BaseArgs, args: &PreviewCommonArgs, client: ApiClient, - trace_project_id: Option, - entry: ViewManifestEntry, - dependency_paths: Vec, - preview_data: Value, + project: Option, + target: PreviewTarget, + source: PreviewSource, ) -> Result<()> { let listener = TcpListener::bind(("127.0.0.1", args.port)) .with_context(|| format!("failed to bind preview server on port {}", args.port))?; @@ -1036,23 +1062,29 @@ async fn serve_preview( .local_addr() .context("failed to read preview address")?; let url = format!("http://{addr}"); + let title = preview_title(&source); let state = web::Data::new(PreviewServerState { client, - project_id: trace_project_id, - data: preview_data, - source: PreviewSource { - path: args.path.clone(), - view: args.view.clone(), - view_type: Some(entry.view_type), - }, - title: entry.name.clone(), - dependency_paths: Mutex::new(dependency_paths), + project, + target, + source, + title, + dependency_paths: Mutex::new(Vec::new()), + trace_project_id: Mutex::new(None), + trace_data: Mutex::new(None), }); let server = HttpServer::new(move || { App::new() .app_data(state.clone()) .route("/", web::get().to(preview_index)) + .route("/preview-assets/{asset}.js", web::get().to(preview_asset)) + .route("/preview-module/{tail:.*}", web::get().to(preview_module)) + .route( + "/preview-virtual/{module}.js", + web::get().to(preview_virtual_module), + ) .route("/preview-version", web::get().to(preview_version)) + .route("/preview-data", web::post().to(preview_data)) .route("/span-fields", web::post().to(preview_span_fields)) }) .workers(1) @@ -1067,14 +1099,13 @@ async fn serve_preview( serde_json::to_string_pretty(&json!({ "url": url, "view": { - "name": entry.name, - "slug": entry.slug, - "type": entry.view_type, + "path": args.path.display().to_string(), + "selector": args.view, }, }))? ); } else { - println!("Previewing {} at {}", entry.name, url); + println!("Previewing {} at {}", args.path.display(), url); } if !args.no_open { @@ -1088,12 +1119,6 @@ async fn serve_preview( Ok(()) } -struct PreviewPage { - title: String, - code: String, - dependency_paths: Vec, -} - async fn preview_index(state: web::Data) -> HttpResponse { let dependency_paths = state .dependency_paths @@ -1101,20 +1126,48 @@ async fn preview_index(state: web::Data) -> HttpResponse { .map(|paths| paths.clone()) .unwrap_or_default(); let version = preview_source_version(&state.source, &dependency_paths); - let html = match load_preview_page(&state.source) { - Ok(page) => { - if let Ok(mut dependency_paths) = state.dependency_paths.lock() { - *dependency_paths = page.dependency_paths.clone(); - } - render_preview_html(&page.title, &page.code, &state.data, &version) - } - Err(err) => render_preview_error_html(&state.title, &format!("{err:#}"), &version), - }; + let html = render_preview_html(&state.title, &state.source, &version); HttpResponse::Ok() .content_type("text/html; charset=utf-8") .body(html) } +async fn preview_asset(asset: web::Path) -> HttpResponse { + match preview_asset_source(&asset) { + Some(code) => HttpResponse::Ok() + .content_type("application/javascript; charset=utf-8") + .body(code), + None => HttpResponse::NotFound().json(json!({ + "error": format!("unknown preview asset '{}'", asset.as_str()), + })), + } +} + +async fn preview_module( + state: web::Data, + tail: web::Path, +) -> HttpResponse { + match preview_module_result(&state, &tail.into_inner()) { + Ok(code) => HttpResponse::Ok() + .content_type("application/javascript; charset=utf-8") + .body(code), + Err(err) => HttpResponse::BadRequest().json(json!({ + "error": format!("{err:#}"), + })), + } +} + +async fn preview_virtual_module(module: web::Path) -> HttpResponse { + match preview_virtual_module_source(&module) { + Some(code) => HttpResponse::Ok() + .content_type("application/javascript; charset=utf-8") + .body(code), + None => HttpResponse::NotFound().json(json!({ + "error": format!("unknown preview virtual module '{}'", module.as_str()), + })), + } +} + async fn preview_version(state: web::Data) -> HttpResponse { let dependency_paths = state .dependency_paths @@ -1126,20 +1179,40 @@ async fn preview_version(state: web::Data) -> HttpResponse { })) } +async fn preview_data( + state: web::Data, + body: web::Json, +) -> HttpResponse { + match preview_data_result(&state, body.into_inner()).await { + Ok(data) => HttpResponse::Ok().json(data), + Err(err) => HttpResponse::BadRequest().json(json!({ + "error": format!("{err:#}"), + })), + } +} + async fn preview_span_fields( state: web::Data, body: web::Json, ) -> HttpResponse { - let Some(project_id) = state.project_id.as_deref() else { + let project_id = match state.trace_project_id.lock() { + Ok(project_id) => project_id.clone(), + Err(_) => None, + }; + let Some(project_id) = project_id else { return HttpResponse::BadRequest().json(json!({ "error": "fetchSpanFields is only available for trace previews" })); }; let mut response = Map::new(); - let spans = state - .data - .get("trace") + let trace_data = match state.trace_data.lock() { + Ok(trace_data) => trace_data.clone(), + Err(_) => None, + }; + let spans = trace_data + .as_ref() + .and_then(|data| data.get("trace")) .and_then(|trace| trace.get("spans")) .and_then(Value::as_object) .cloned() @@ -1147,7 +1220,7 @@ async fn preview_span_fields( for span_id in &body.span_ids { let row_id = resolve_preview_row_id(&spans, span_id).unwrap_or_else(|| span_id.clone()); - match fetch_full_span_row(&state.client, project_id, &row_id).await { + match fetch_full_span_row(&state.client, &project_id, &row_id).await { Ok(Some(row)) => { response.insert( span_id.clone(), @@ -1168,28 +1241,379 @@ async fn preview_span_fields( HttpResponse::Ok().json(Value::Object(response)) } -fn load_preview_page(source: &PreviewSource) -> Result { - let files = vec![source.path.clone()]; - let manifest = run_views_runner(&files)?; - validate_manifest_runtime(&manifest)?; - let entry = select_preview_entry(&manifest, source.view.as_deref(), source.view_type)?; - let dependency_paths = preview_dependency_paths(&manifest); - Ok(PreviewPage { - title: entry.name.clone(), - code: entry.code.clone(), - dependency_paths, +fn prepare_preview_source( + path: &Path, + view: Option, + view_type: ViewType, +) -> Result { + if !path.exists() { + bail!("custom view file not found: {}", path.display()); + } + if !path.is_file() { + bail!( + "custom view preview requires a single view file; got {}", + path.display() + ); + } + if !is_view_file(path) { + bail!("custom view preview path must match {VIEW_FILE_PATTERN_HELP}"); + } + let path = std::fs::canonicalize(path) + .with_context(|| format!("failed to resolve custom view file {}", path.display()))?; + let root = path + .parent() + .ok_or_else(|| { + anyhow!( + "custom view file has no parent directory: {}", + path.display() + ) + })? + .to_path_buf(); + Ok(PreviewSource { + path, + root, + view, + view_type, }) } -fn preview_dependency_paths(manifest: &ViewsManifest) -> Vec { - let mut paths = BTreeSet::new(); - for file in &manifest.files { - paths.insert(file.source_file.clone()); - for dependency in &file.dependencies { - paths.insert(dependency.clone()); +fn preview_title(source: &PreviewSource) -> String { + source + .path + .file_name() + .and_then(|name| name.to_str()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| source.view_type.label().to_string()) +} + +async fn preview_data_result( + state: &PreviewServerState, + request: PreviewDataRequest, +) -> Result { + if request.view_type != state.source.view_type { + bail!( + "selected view is {}, but this preview expects {}", + request.view_type.label(), + state.source.view_type.label() + ); + } + if let Some(selector) = state.source.view.as_deref() { + if request.slug != selector && request.name != selector { + bail!( + "{} custom view '{selector}' not found in selected file", + state.source.view_type.label() + ); + } + } + + let entry = ViewManifestEntry { + view_type: request.view_type, + name: request.name, + slug: request.slug, + code: String::new(), + project_id: request.project_id, + project_name: request.project_name, + dataset_id: request.dataset_id, + dataset_name: request.dataset_name, + }; + + match &state.target { + PreviewTarget::Trace(args) => { + let project = match state.project.as_ref() { + Some(default_project) => { + Some(resolve_project_for_entry(&state.client, default_project, &entry).await?) + } + None => None, + }; + let trace_data = + build_trace_preview_data(&state.client, project.as_ref(), args).await?; + if let Ok(mut project_id) = state.trace_project_id.lock() { + *project_id = Some(trace_data.project_id.clone()); + } + if let Ok(mut data) = state.trace_data.lock() { + *data = Some(trace_data.data.clone()); + } + Ok(trace_data.data) + } + PreviewTarget::Dataset(args) => { + let default_project = state + .project + .as_ref() + .ok_or_else(|| anyhow!("dataset preview requires a project"))?; + let project = resolve_project_for_entry(&state.client, default_project, &entry).await?; + build_dataset_preview_data(&state.client, &project, &entry, args).await + } + } +} + +fn preview_module_result(state: &PreviewServerState, tail: &str) -> Result { + let path = preview_module_path_from_route(&state.source, tail)?; + let code = compile_preview_module(&path)?; + if let Ok(mut dependency_paths) = state.dependency_paths.lock() { + dependency_paths.push(path); + dependency_paths.sort(); + dependency_paths.dedup(); + } + Ok(code) +} + +fn preview_asset_source(asset: &str) -> Option<&'static str> { + match asset { + "react" => Some(VIEWS_PREVIEW_REACT_SOURCE), + "react-dom" => Some(VIEWS_PREVIEW_REACT_DOM_SOURCE), + "tailwindcss-browser" => Some(VIEWS_PREVIEW_TAILWIND_SOURCE), + _ => None, + } +} + +fn preview_virtual_module_source(module: &str) -> Option<&'static str> { + match module { + "custom-views" => Some( + r#"export function customTraceView(definition, component) { + return { ...definition, component, kind: "trace" }; +} +export function customDatasetView(definition, component) { + return { ...definition, component, kind: "dataset" }; +} +"#, + ), + "react" => Some( + r#"const ReactValue = globalThis.React; +export default ReactValue; +export const Children = ReactValue.Children; +export const Component = ReactValue.Component; +export const Fragment = ReactValue.Fragment; +export const Profiler = ReactValue.Profiler; +export const PureComponent = ReactValue.PureComponent; +export const StrictMode = ReactValue.StrictMode; +export const Suspense = ReactValue.Suspense; +export const cloneElement = ReactValue.cloneElement; +export const createContext = ReactValue.createContext; +export const createElement = ReactValue.createElement; +export const createRef = ReactValue.createRef; +export const forwardRef = ReactValue.forwardRef; +export const isValidElement = ReactValue.isValidElement; +export const lazy = ReactValue.lazy; +export const memo = ReactValue.memo; +export const startTransition = ReactValue.startTransition; +export const useCallback = ReactValue.useCallback; +export const useContext = ReactValue.useContext; +export const useDebugValue = ReactValue.useDebugValue; +export const useDeferredValue = ReactValue.useDeferredValue; +export const useEffect = ReactValue.useEffect; +export const useId = ReactValue.useId; +export const useImperativeHandle = ReactValue.useImperativeHandle; +export const useInsertionEffect = ReactValue.useInsertionEffect; +export const useLayoutEffect = ReactValue.useLayoutEffect; +export const useMemo = ReactValue.useMemo; +export const useReducer = ReactValue.useReducer; +export const useRef = ReactValue.useRef; +export const useState = ReactValue.useState; +export const useSyncExternalStore = ReactValue.useSyncExternalStore; +export const useTransition = ReactValue.useTransition; +"#, + ), + "react-jsx-runtime" => Some( + r#"const ReactValue = globalThis.React; +export const Fragment = ReactValue.Fragment; +export function jsx(type, props, key) { + return ReactValue.createElement(type, key === undefined ? props : { ...props, key }); +} +export const jsxs = jsx; +export const jsxDEV = jsx; +"#, + ), + _ => None, + } +} + +fn compile_preview_module(path: &Path) -> Result { + let source = std::fs::read_to_string(path) + .with_context(|| format!("failed to read preview module {}", path.display()))?; + if path + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("json")) + { + let json: Value = serde_json::from_str(&source) + .with_context(|| format!("failed to parse preview JSON module {}", path.display()))?; + return Ok(format!("export default {};\n", script_json(&json))); + } + + let allocator = Allocator::default(); + let source_type = SourceType::from_path(path).map_err(|err| { + anyhow!( + "failed to infer preview module type for {}: {err}", + path.display() + ) + })?; + let mut parsed = Parser::new(&allocator, &source, source_type).parse(); + if !parsed.errors.is_empty() { + bail!( + "failed to parse preview module {}:\n{}", + path.display(), + parsed + .errors + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n") + ); + } + + let scoping = { + let semantic = SemanticBuilder::new() + .with_check_syntax_error(true) + .build(&parsed.program); + if !semantic.errors.is_empty() { + bail!( + "failed to analyze preview module {}:\n{}", + path.display(), + semantic + .errors + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n") + ); } + semantic.semantic.into_scoping() + }; + + let transform_options = TransformOptions::default(); + let transformed = Transformer::new(&allocator, path, &transform_options) + .build_with_scoping(scoping, &mut parsed.program); + if !transformed.errors.is_empty() { + bail!( + "failed to transform preview module {}:\n{}", + path.display(), + transformed + .errors + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n") + ); } - paths.into_iter().map(PathBuf::from).collect() + + Ok(Codegen::new() + .with_options(CodegenOptions { + comments: oxc_codegen::CommentOptions::disabled(), + ..CodegenOptions::default() + }) + .build(&parsed.program) + .code) +} + +fn preview_module_path_from_route(source: &PreviewSource, tail: &str) -> Result { + if tail.trim().is_empty() { + bail!("preview module path is empty"); + } + let decoded = urlencoding::decode(tail) + .with_context(|| format!("failed to decode preview module path '{tail}'"))?; + let raw = preview_route_tail_to_path(&decoded); + preview_source_path_from_raw(source, &raw) +} + +fn preview_route_tail_to_path(tail: &str) -> PathBuf { + #[cfg(windows)] + { + PathBuf::from(tail.replace('/', "\\")) + } + #[cfg(not(windows))] + { + Path::new("/").join(tail.trim_start_matches('/')) + } +} + +fn preview_module_url(path: &Path) -> String { + let mut url = String::from("/preview-module"); + for component in path.components() { + match component { + std::path::Component::Prefix(prefix) => { + url.push('/'); + url.push_str(&encode(&prefix.as_os_str().to_string_lossy())); + } + std::path::Component::RootDir | std::path::Component::CurDir => {} + std::path::Component::Normal(part) => { + url.push('/'); + url.push_str(&encode(&part.to_string_lossy())); + } + std::path::Component::ParentDir => {} + } + } + url +} + +#[cfg(test)] +fn preview_source_path_from_request(source: &PreviewSource, requested: &str) -> Result { + let path = PathBuf::from(requested); + preview_source_path_from_raw(source, &path) +} + +fn preview_source_path_from_raw(source: &PreviewSource, path: &Path) -> Result { + for candidate in preview_resolution_candidates(path) { + if candidate.is_file() { + let candidate = std::fs::canonicalize(&candidate).with_context(|| { + format!("failed to resolve preview source {}", candidate.display()) + })?; + ensure_preview_path_allowed(source, &candidate)?; + return Ok(candidate); + } + } + + bail!("preview source not found: {}", path.display()); +} + +fn ensure_preview_path_allowed(source: &PreviewSource, path: &Path) -> Result<()> { + if !path.starts_with(&source.root) { + bail!( + "preview source must be inside {}: {}", + source.root.display(), + path.display() + ); + } + Ok(()) +} + +#[cfg(test)] +fn resolve_preview_source_path( + source: &PreviewSource, + importer: Option<&str>, + specifier: &str, +) -> Result { + let raw = if Path::new(specifier).is_absolute() { + PathBuf::from(specifier) + } else if specifier.starts_with('.') { + let importer = importer + .map(PathBuf::from) + .unwrap_or_else(|| source.path.clone()); + let importer = preview_source_path_from_request(source, &importer.display().to_string())?; + importer + .parent() + .ok_or_else(|| anyhow!("preview importer has no parent: {}", importer.display()))? + .join(specifier) + } else { + bail!("preview only supports relative local imports; unsupported import '{specifier}'"); + }; + + preview_source_path_from_raw(source, &raw) + .with_context(|| format!("preview import '{specifier}' not found")) +} + +fn preview_resolution_candidates(raw: &Path) -> Vec { + const EXTENSIONS: &[&str] = &["tsx", "ts", "jsx", "js", "mjs", "cjs", "json"]; + let mut candidates = Vec::new(); + candidates.push(raw.to_path_buf()); + if raw.extension().is_none() { + for extension in EXTENSIONS { + candidates.push(raw.with_extension(extension)); + } + } + for extension in EXTENSIONS { + candidates.push(raw.join(format!("index.{extension}"))); + } + candidates } fn preview_source_version(source: &PreviewSource, dependency_paths: &[PathBuf]) -> String { @@ -1220,188 +1644,18 @@ fn push_preview_file_version(parts: &mut Vec, path: &Path) { } } -fn render_preview_html(title: &str, code: &str, data: &Value, version: &str) -> String { - let data_json = script_json(data); - let code_json = script_json(&Value::String(code.to_string())); - let title_json = script_json(&Value::String(title.to_string())); - let version_json = script_json(&Value::String(version.to_string())); - format!( - r##" - - - - - {} - - - - - - -
- - -"##, - html_escape(title), - ) -} - -fn render_preview_error_html(title: &str, error: &str, version: &str) -> String { - let title_json = script_json(&Value::String(title.to_string())); - let error_json = script_json(&Value::String(error.to_string())); - let version_json = script_json(&Value::String(version.to_string())); - format!( - r##" - - - - - {} - - - -

-  
-
-"##,
-        html_escape(title),
-    )
+fn render_preview_html(title: &str, source: &PreviewSource, version: &str) -> String {
+    let config_json = script_json(&json!({
+        "title": title,
+        "version": version,
+        "sourcePath": source.path.display().to_string(),
+        "sourceModuleUrl": preview_module_url(&source.path),
+        "viewType": source.view_type.label(),
+        "viewSelector": source.view,
+    }));
+    VIEWS_PREVIEW_HTML_TEMPLATE
+        .replace("__HTML_TITLE__", &html_escape(title))
+        .replace("__PREVIEW_CONFIG__", &config_json)
 }
 
 fn script_json(value: &Value) -> String {
@@ -1486,27 +1740,107 @@ fn is_view_file(path: &Path) -> bool {
 }
 
 fn run_views_runner(files: &[PathBuf]) -> Result {
-    let sdk_script = js_runner::materialize_runner_script_in_cwd(
-        "views-runners",
-        VIEWS_JS_SDK_FILE,
-        VIEWS_JS_SDK_SOURCE,
-    )
-    .context("failed to materialize custom views SDK helper")?;
-    let runner_script = js_runner::materialize_runner_script_in_cwd(
-        "views-runners",
-        VIEWS_JS_RUNNER_FILE,
-        VIEWS_JS_RUNNER_SOURCE,
+    let temp_dir = tempfile::tempdir().context("failed to create custom views temp directory")?;
+    let mut input_files = Vec::new();
+    let mut bundled_files = BTreeMap::new();
+    for file in files {
+        let source_file = std::fs::canonicalize(file)
+            .with_context(|| format!("failed to resolve custom view file {}", file.display()))?;
+        let source_key = source_file.display().to_string();
+        let safe_name = source_file
+            .file_name()
+            .and_then(|name| name.to_str())
+            .unwrap_or("view")
+            .replace(|character: char| !character.is_ascii_alphanumeric(), "_");
+
+        let discovery =
+            swc_bundle_custom_view(&source_file, temp_dir.path(), SwcBundleTarget::Discovery)?;
+        let discovery_bundle = temp_dir.path().join(format!("{safe_name}.discovery.cjs"));
+        std::fs::write(
+            &discovery_bundle,
+            module_exports_from_swc_iife(&discovery.code),
+        )
+        .with_context(|| {
+            format!(
+                "failed to write custom view discovery bundle {}",
+                discovery_bundle.display()
+            )
+        })?;
+
+        let browser_entry = temp_dir
+            .path()
+            .join(format!("{safe_name}.browser-entry.ts"));
+        std::fs::write(
+            &browser_entry,
+            format!(
+                "import view from {};\nexport default view.component;\n",
+                serde_json::to_string(&source_key)?
+            ),
+        )
+        .with_context(|| {
+            format!(
+                "failed to write custom view browser entry {}",
+                browser_entry.display()
+            )
+        })?;
+        let browser =
+            swc_bundle_custom_view(&browser_entry, temp_dir.path(), SwcBundleTarget::Browser)?;
+
+        let dependencies = discovery
+            .dependencies
+            .into_iter()
+            .chain(browser.dependencies)
+            .collect::>()
+            .into_iter()
+            .map(|path| path.display().to_string())
+            .collect::>();
+        bundled_files.insert(
+            source_key.clone(),
+            SwcBundledViewFile {
+                code: module_exports_from_swc_iife(&browser.code),
+                dependencies,
+            },
+        );
+        input_files.push(ViewsDiscoveryInputFile {
+            source_file: source_key,
+            bundle_file: discovery_bundle.display().to_string(),
+        });
+    }
+    let input_path = temp_dir.path().join("views-discovery-input.json");
+    std::fs::write(
+        &input_path,
+        serde_json::to_vec(&ViewsDiscoveryInput { files: input_files })?,
     )
-    .context("failed to materialize custom views runner")?;
-    let mut command = js_runner::build_js_runner_command(None, &runner_script, files);
-    command.env("BT_VIEWS_SDK_PATH", &sdk_script);
+    .with_context(|| {
+        format!(
+            "failed to write custom views discovery input {}",
+            input_path.display()
+        )
+    })?;
 
-    let output = command.output().with_context(|| {
+    let mut command = Command::new("node");
+    command
+        .arg("--input-type=module")
+        .arg("-")
+        .arg(&input_path)
+        .stdin(Stdio::piped())
+        .stdout(Stdio::piped())
+        .stderr(Stdio::piped());
+    let mut child = command.spawn().with_context(|| {
         format!(
-            "failed to spawn custom views runner: {}",
+            "failed to spawn custom views metadata runner: {}",
             command_display(&command)
         )
     })?;
+    child
+        .stdin
+        .take()
+        .ok_or_else(|| anyhow!("failed to open custom views metadata runner stdin"))?
+        .write_all(VIEWS_JS_RUNNER_SOURCE.as_bytes())
+        .context("failed to write custom views metadata runner source")?;
+    let output = child
+        .wait_with_output()
+        .context("failed to wait for custom views metadata runner")?;
     if !output.status.success() {
         let stderr = String::from_utf8_lossy(&output.stderr);
         let stdout = String::from_utf8_lossy(&output.stdout);
@@ -1517,22 +1851,358 @@ fn run_views_runner(files: &[PathBuf]) -> Result {
             details
         };
         bail!(
-            "custom views runner exited with status {}: {}",
+            "custom views metadata runner exited with status {}: {}",
             output.status,
             details
         );
     }
 
-    let stdout =
-        String::from_utf8(output.stdout).context("custom views runner output was not UTF-8")?;
-    serde_json::from_str(&stdout).with_context(|| {
+    let stdout = String::from_utf8(output.stdout)
+        .context("custom views metadata runner output was not UTF-8")?;
+    let mut manifest: ViewsManifest = serde_json::from_str(&stdout).with_context(|| {
         format!(
-            "failed to parse custom views runner output as JSON: {}",
+            "failed to parse custom views metadata runner output as JSON: {}",
             stdout.trim()
         )
+    })?;
+    for file in &mut manifest.files {
+        if let Some(bundled) = bundled_files.get(&file.source_file) {
+            file.dependencies.clone_from(&bundled.dependencies);
+            for entry in &mut file.entries {
+                entry.code.clone_from(&bundled.code);
+            }
+        }
+    }
+    Ok(manifest)
+}
+
+#[derive(Debug, Clone, Copy)]
+enum SwcBundleTarget {
+    Discovery,
+    Browser,
+}
+
+#[derive(Debug)]
+struct SwcBundledViewFile {
+    code: String,
+    dependencies: Vec,
+}
+
+#[derive(Debug)]
+struct SwcBundleOutput {
+    code: String,
+    dependencies: Vec,
+}
+
+struct ViewsSwcResolver {
+    node: NodeModulesResolver,
+}
+
+impl ViewsSwcResolver {
+    fn new(target: SwcBundleTarget) -> Self {
+        let target_env = match target {
+            SwcBundleTarget::Discovery => SwcTargetEnv::Node,
+            SwcBundleTarget::Browser => SwcTargetEnv::Browser,
+        };
+        Self {
+            node: NodeModulesResolver::new(target_env, Default::default(), false),
+        }
+    }
+}
+
+impl Resolve for ViewsSwcResolver {
+    fn resolve(&self, base: &FileName, module_specifier: &str) -> Result {
+        if is_views_virtual_module(module_specifier) {
+            return Ok(Resolution {
+                filename: FileName::Custom(module_specifier.to_string()),
+                slug: None,
+            });
+        }
+        self.node.resolve(base, module_specifier)
+    }
+}
+
+struct ViewsSwcLoader {
+    cm: Lrc,
+    temp_dir: PathBuf,
+    dependencies: Arc>>,
+}
+
+impl Load for ViewsSwcLoader {
+    fn load(&self, file: &FileName) -> Result {
+        let (fm, syntax) = match file {
+            FileName::Real(path) => {
+                let source = std::fs::read_to_string(path).with_context(|| {
+                    format!("failed to read custom view module {}", path.display())
+                })?;
+                let resolved = std::fs::canonicalize(path).with_context(|| {
+                    format!("failed to resolve custom view module {}", path.display())
+                })?;
+                if !resolved.starts_with(&self.temp_dir) {
+                    self.dependencies
+                        .lock()
+                        .expect("custom view dependency lock")
+                        .insert(resolved);
+                }
+                (
+                    self.cm
+                        .new_source_file(Lrc::new(FileName::Real(path.clone())), source),
+                    swc_syntax_for_path(path),
+                )
+            }
+            FileName::Custom(name) if name == "braintrust/custom-views" => (
+                self.cm
+                    .new_source_file(Lrc::new(file.clone()), VIEWS_JS_SDK_SOURCE.to_string()),
+                Syntax::Typescript(TsSyntax::default()),
+            ),
+            FileName::Custom(name) if name == "@braintrust/local/custom-views" => (
+                self.cm
+                    .new_source_file(Lrc::new(file.clone()), VIEWS_JS_SDK_SOURCE.to_string()),
+                Syntax::Typescript(TsSyntax::default()),
+            ),
+            FileName::Custom(name) if name == "react" => (
+                self.cm
+                    .new_source_file(Lrc::new(file.clone()), react_module_source()),
+                Syntax::Es(EsSyntax::default()),
+            ),
+            FileName::Custom(name)
+                if name == "react/jsx-runtime" || name == "react/jsx-dev-runtime" =>
+            {
+                (
+                    self.cm
+                        .new_source_file(Lrc::new(file.clone()), jsx_runtime_module_source()),
+                    Syntax::Es(EsSyntax::default()),
+                )
+            }
+            FileName::Custom(name) => bail!("unsupported custom view virtual module '{name}'"),
+            _ => bail!("unsupported custom view module {}", file),
+        };
+
+        let mut errors = Vec::new();
+        let module = parse_file_as_module(&fm, syntax, EsVersion::Es2022, None, &mut errors)
+            .map_err(|err| anyhow!("{err:?}"))
+            .with_context(|| format!("failed to parse custom view module {}", file))?;
+        if !errors.is_empty() {
+            let details = errors
+                .iter()
+                .map(|err| format!("{err:?}"))
+                .collect::>()
+                .join("; ");
+            bail!("failed to parse custom view module {}: {details}", file);
+        }
+
+        let unresolved_mark = Mark::new();
+        let top_level_mark = Mark::new();
+        let mut program = Program::Module(module);
+        program.mutate(resolver(
+            unresolved_mark,
+            top_level_mark,
+            syntax_is_typescript(syntax),
+        ));
+        if syntax_is_typescript(syntax) {
+            program.mutate(strip_typescript(unresolved_mark, top_level_mark));
+        }
+        program.mutate(react(
+            self.cm.clone(),
+            None::,
+            ReactOptions {
+                runtime: Some(ReactRuntime::Classic),
+                pragma: Some("React.createElement".into()),
+                pragma_frag: Some("React.Fragment".into()),
+                development: Some(false),
+                ..Default::default()
+            },
+            top_level_mark,
+            unresolved_mark,
+        ));
+        program.mutate(fixer(None));
+
+        let Program::Module(module) = program else {
+            bail!("custom view module {} did not parse as an ES module", file);
+        };
+        Ok(ModuleData {
+            fm,
+            module,
+            helpers: Helpers::new(false),
+        })
+    }
+}
+
+struct ViewsSwcHook;
+
+impl Hook for ViewsSwcHook {
+    fn get_import_meta_props(
+        &self,
+        _span: Span,
+        _module_record: &ModuleRecord,
+    ) -> Result> {
+        Ok(Vec::new())
+    }
+}
+
+fn swc_bundle_custom_view(
+    entry: &Path,
+    temp_dir: &Path,
+    target: SwcBundleTarget,
+) -> Result {
+    let entry = std::fs::canonicalize(entry)
+        .with_context(|| format!("failed to resolve custom view entry {}", entry.display()))?;
+    let temp_dir = std::fs::canonicalize(temp_dir).with_context(|| {
+        format!(
+            "failed to resolve custom view temp directory {}",
+            temp_dir.display()
+        )
+    })?;
+    let cm = Lrc::new(SourceMap::default());
+    let globals = Globals::new();
+    let dependencies = Arc::new(Mutex::new(BTreeSet::new()));
+    let loader = ViewsSwcLoader {
+        cm: cm.clone(),
+        temp_dir,
+        dependencies: dependencies.clone(),
+    };
+    let resolver = ViewsSwcResolver::new(target);
+    let mut bundler = Bundler::new(
+        &globals,
+        cm.clone(),
+        loader,
+        resolver,
+        SwcBundlerConfig {
+            module: ModuleType::Iife,
+            ..Default::default()
+        },
+        Box::new(ViewsSwcHook),
+    );
+    let bundles = bundler
+        .bundle(HashMap::from([(
+            "custom-view".to_string(),
+            FileName::Real(entry.clone()),
+        )]))
+        .with_context(|| format!("failed to bundle custom view {}", entry.display()))?;
+    let bundle = single_swc_bundle(bundles, &entry)?;
+    Ok(SwcBundleOutput {
+        code: to_code_default(cm, None, &bundle.module),
+        dependencies: Arc::try_unwrap(dependencies)
+            .unwrap_or_else(|dependencies| {
+                Mutex::new(
+                    dependencies
+                        .lock()
+                        .expect("custom view dependency lock")
+                        .clone(),
+                )
+            })
+            .into_inner()
+            .expect("custom view dependency lock")
+            .into_iter()
+            .collect(),
     })
 }
 
+fn single_swc_bundle(mut bundles: Vec, entry: &Path) -> Result {
+    if bundles.len() != 1 {
+        bail!(
+            "expected one custom view bundle for {}, got {}",
+            entry.display(),
+            bundles.len()
+        );
+    }
+    let bundle = bundles.remove(0);
+    if !matches!(bundle.kind, BundleKind::Named { .. }) {
+        bail!(
+            "custom view bundle for {} was not an entry bundle",
+            entry.display()
+        );
+    }
+    Ok(bundle)
+}
+
+fn module_exports_from_swc_iife(code: &str) -> String {
+    let expression = code.trim().trim_end_matches(';');
+    format!(
+        "var __BraintrustCustomView = {expression};\nmodule.exports = __BraintrustCustomView;\n"
+    )
+}
+
+fn is_views_virtual_module(module_specifier: &str) -> bool {
+    matches!(
+        module_specifier,
+        "braintrust/custom-views"
+            | "@braintrust/local/custom-views"
+            | "react"
+            | "react/jsx-runtime"
+            | "react/jsx-dev-runtime"
+    )
+}
+
+fn swc_syntax_for_path(path: &Path) -> Syntax {
+    match path.extension().and_then(|extension| extension.to_str()) {
+        Some("ts") | Some("mts") | Some("cts") => Syntax::Typescript(TsSyntax::default()),
+        Some("tsx") => Syntax::Typescript(TsSyntax {
+            tsx: true,
+            ..Default::default()
+        }),
+        Some("jsx") => Syntax::Es(EsSyntax {
+            jsx: true,
+            ..Default::default()
+        }),
+        _ => Syntax::Es(EsSyntax::default()),
+    }
+}
+
+fn syntax_is_typescript(syntax: Syntax) -> bool {
+    matches!(syntax, Syntax::Typescript(_))
+}
+
+fn react_module_source() -> String {
+    r#"
+const ReactValue = globalThis.React || React;
+export default ReactValue;
+export const Children = ReactValue.Children;
+export const Component = ReactValue.Component;
+export const Fragment = ReactValue.Fragment;
+export const Profiler = ReactValue.Profiler;
+export const PureComponent = ReactValue.PureComponent;
+export const StrictMode = ReactValue.StrictMode;
+export const Suspense = ReactValue.Suspense;
+export const cloneElement = ReactValue.cloneElement;
+export const createContext = ReactValue.createContext;
+export const createElement = ReactValue.createElement;
+export const createRef = ReactValue.createRef;
+export const forwardRef = ReactValue.forwardRef;
+export const isValidElement = ReactValue.isValidElement;
+export const lazy = ReactValue.lazy;
+export const memo = ReactValue.memo;
+export const startTransition = ReactValue.startTransition;
+export const useCallback = ReactValue.useCallback;
+export const useContext = ReactValue.useContext;
+export const useDebugValue = ReactValue.useDebugValue;
+export const useDeferredValue = ReactValue.useDeferredValue;
+export const useEffect = ReactValue.useEffect;
+export const useId = ReactValue.useId;
+export const useImperativeHandle = ReactValue.useImperativeHandle;
+export const useInsertionEffect = ReactValue.useInsertionEffect;
+export const useLayoutEffect = ReactValue.useLayoutEffect;
+export const useMemo = ReactValue.useMemo;
+export const useReducer = ReactValue.useReducer;
+export const useRef = ReactValue.useRef;
+export const useState = ReactValue.useState;
+export const useSyncExternalStore = ReactValue.useSyncExternalStore;
+export const useTransition = ReactValue.useTransition;
+"#
+    .to_string()
+}
+
+fn jsx_runtime_module_source() -> String {
+    r#"
+const ReactValue = globalThis.React || React;
+export const Fragment = ReactValue.Fragment;
+export const jsx = ReactValue.createElement;
+export const jsxs = ReactValue.createElement;
+export const jsxDEV = ReactValue.createElement;
+"#
+    .to_string()
+}
+
 fn command_display(command: &Command) -> String {
     let mut rendered = command.get_program().to_string_lossy().to_string();
     for arg in command.get_args() {
@@ -1777,61 +2447,6 @@ fn custom_view_url(
     }
 }
 
-fn select_preview_entry<'a>(
-    manifest: &'a ViewsManifest,
-    selector: Option<&str>,
-    view_type: Option,
-) -> Result<&'a ViewManifestEntry> {
-    let entries = manifest
-        .files
-        .iter()
-        .flat_map(|file| file.entries.iter())
-        .filter(|entry| view_type.is_none_or(|view_type| entry.view_type == view_type))
-        .collect::>();
-    if entries.is_empty() {
-        match view_type {
-            Some(view_type) => bail!(
-                "selected file did not register any {} custom views",
-                view_type.label()
-            ),
-            None => bail!("selected file did not register any custom views"),
-        }
-    }
-    if let Some(selector) = selector {
-        let matches = entries
-            .into_iter()
-            .filter(|entry| entry.slug == selector || entry.name == selector)
-            .collect::>();
-        return match matches.as_slice() {
-            [entry] => Ok(*entry),
-            [] => match view_type {
-                Some(view_type) => bail!(
-                    "{} custom view '{selector}' not found in selected file",
-                    view_type.label()
-                ),
-                None => bail!("custom view '{selector}' not found in selected file"),
-            },
-            _ => match view_type {
-                Some(view_type) => bail!(
-                    "{} custom view selector '{selector}' matched multiple views",
-                    view_type.label()
-                ),
-                None => bail!("custom view selector '{selector}' matched multiple views"),
-            },
-        };
-    }
-    if entries.len() == 1 {
-        return Ok(entries[0]);
-    }
-    match view_type {
-        Some(view_type) => bail!(
-            "selected file registers multiple {} custom views; pass --view ",
-            view_type.label()
-        ),
-        None => bail!("selected file registers multiple custom views; pass --view "),
-    }
-}
-
 async fn build_dataset_preview_data(
     client: &ApiClient,
     project: &Project,
@@ -2292,26 +2907,183 @@ mod tests {
     }
 
     #[test]
-    fn preview_html_includes_hot_reload_polling() {
-        let html = render_preview_html(
-            "Preview",
-            "module.exports = function View() {}",
-            &json!({}),
-            "v1",
+    fn push_runner_bundles_tsx_with_swc() {
+        let dir = tempfile::tempdir().expect("tempdir");
+        let dependency = dir.path().join("label.ts");
+        let path = dir.path().join("swc.trace-view.tsx");
+        std::fs::write(&dependency, "export const label = 'SWC bundled';\n")
+            .expect("write dependency");
+        std::fs::write(
+            &path,
+            r#"
+import { customTraceView } from "braintrust/custom-views";
+import { label } from "./label";
+
+export default customTraceView(
+  { name: "SWC Trace", slug: "swc-trace", project: { id: "proj_test" } },
+  function SwcTraceView() {
+    return 
{label}
; + }, +); +"#, + ) + .expect("write view"); + + let manifest = run_views_runner(&[path.clone()]).expect("views manifest"); + + assert_eq!(manifest.runtime_context.runtime, "browser"); + assert_eq!(manifest.files.len(), 1); + let file = &manifest.files[0]; + assert_eq!( + file.source_file, + path.canonicalize() + .expect("canonical view") + .display() + .to_string() ); + assert!(file.dependencies.iter().any(|item| item + == &dependency + .canonicalize() + .expect("canonical dependency") + .display() + .to_string())); + assert_eq!(file.entries.len(), 1); + let entry = &file.entries[0]; + assert_eq!(entry.view_type, ViewType::Trace); + assert_eq!(entry.name, "SWC Trace"); + assert_eq!(entry.slug, "swc-trace"); + assert_eq!(entry.project_id.as_deref(), Some("proj_test")); + assert!(entry + .code + .contains("module.exports = __BraintrustCustomView")); + assert!(entry.code.contains("React.createElement")); + assert!(!entry.code.contains("esbuild")); + assert!(!entry.code.contains(": string")); + } + + fn test_preview_source(path: PathBuf, root: PathBuf) -> PreviewSource { + PreviewSource { + path, + root, + view: None, + view_type: ViewType::Trace, + } + } + + #[test] + fn preview_html_includes_hot_reload_polling() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("test.trace-view.tsx"); + std::fs::write(&path, "export default null;\n").expect("write view"); + let source = test_preview_source(path, dir.path().to_path_buf()); + let html = render_preview_html("Preview", &source, "v1"); assert!(html.contains("previewVersion")); assert!(html.contains("/preview-version")); assert!(html.contains("window.location.reload()")); + assert!(html.contains("sourceModuleUrl")); + assert!(html.contains("/preview-module/")); + assert!(html.contains("/preview-assets/tailwindcss-browser.js")); + assert!(html.contains("/preview-assets/react.js")); + assert!(html.contains("/preview-assets/react-dom.js")); + assert!(!html.contains("https://cdn.tailwindcss.com")); + assert!(!html.contains("https://unpkg.com")); } #[test] - fn preview_error_html_includes_hot_reload_polling() { - let html = render_preview_error_html("Preview", "bundle failed", "v1"); + fn preview_assets_are_embedded_from_npm() { + let react = preview_asset_source("react").expect("react preview asset"); + let react_dom = preview_asset_source("react-dom").expect("react-dom preview asset"); + let tailwind = preview_asset_source("tailwindcss-browser").expect("tailwind preview asset"); - assert!(html.contains("bundle failed")); - assert!(html.contains("/preview-version")); - assert!(html.contains("window.location.reload()")); + assert!(react.contains("React")); + assert!(react_dom.contains("ReactDOM")); + assert!(tailwind.contains("tailwind")); + assert!(preview_asset_source("missing").is_none()); + } + + #[test] + fn preview_source_path_allows_files_inside_root() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("test.trace-view.tsx"); + let dependency = dir.path().join("component.tsx"); + std::fs::write(&path, "export { default } from './component';\n").expect("write view"); + std::fs::write(&dependency, "export default null;\n").expect("write dependency"); + let source = test_preview_source(path, dir.path().canonicalize().expect("root")); + + let resolved = preview_source_path_from_request(&source, &dependency.display().to_string()) + .expect("dependency should be allowed"); + + assert_eq!( + resolved, + dependency.canonicalize().expect("canonical dependency") + ); + } + + #[test] + fn preview_source_path_rejects_files_outside_root() { + let dir = tempfile::tempdir().expect("tempdir"); + let outside_dir = tempfile::tempdir().expect("outside tempdir"); + let path = dir.path().join("test.trace-view.tsx"); + let outside = outside_dir.path().join("component.tsx"); + std::fs::write(&path, "export default null;\n").expect("write view"); + std::fs::write(&outside, "export default null;\n").expect("write outside dependency"); + let source = test_preview_source(path, dir.path().canonicalize().expect("root")); + + let error = preview_source_path_from_request(&source, &outside.display().to_string()) + .expect_err("outside dependency should be rejected"); + + assert!(format!("{error:#}").contains("preview source must be inside")); + } + + #[test] + fn preview_relative_import_resolution_stays_inside_root() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("test.trace-view.tsx"); + let dependency = dir.path().join("component.tsx"); + std::fs::write(&path, "export { default } from './component';\n").expect("write view"); + std::fs::write(&dependency, "export default null;\n").expect("write dependency"); + let source = test_preview_source( + path.canonicalize().expect("canonical view"), + dir.path().canonicalize().expect("root"), + ); + + let resolved = resolve_preview_source_path( + &source, + Some(&source.path.display().to_string()), + "./component", + ) + .expect("relative import should resolve"); + + assert_eq!( + resolved, + dependency.canonicalize().expect("canonical dependency") + ); + } + + #[test] + fn preview_module_compiles_tsx_with_oxc() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("test.trace-view.tsx"); + std::fs::write( + &path, + r#"import { customTraceView } from "braintrust/custom-views"; + +type Props = { span: { id: string } }; + +export default customTraceView({ name: "Test View", slug: "test-view" }, ({ span }: Props) => { + return
{span.id}
; +}); +"#, + ) + .expect("write view"); + + let code = compile_preview_module(&path).expect("compile TSX preview module"); + + assert!(code.contains("react/jsx-runtime")); + assert!(code.contains("customTraceView")); + assert!(!code.contains("type Props")); + assert!(!code.contains("
")); } #[test] @@ -2319,11 +3091,7 @@ mod tests { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("test.trace-view.tsx"); std::fs::write(&path, "export default null;\n").expect("write view"); - let source = PreviewSource { - path: path.clone(), - view: None, - view_type: Some(ViewType::Trace), - }; + let source = test_preview_source(path.clone(), dir.path().to_path_buf()); let before = preview_source_version(&source, &[]); std::fs::write(&path, "export default function View() { return null; }\n") @@ -2344,11 +3112,7 @@ mod tests { "export default function View() { return null; }\n", ) .expect("write dependency"); - let source = PreviewSource { - path, - view: None, - view_type: Some(ViewType::Trace), - }; + let source = test_preview_source(path, dir.path().to_path_buf()); let dependency_paths = vec![dependency.clone()]; let before = preview_source_version(&source, &dependency_paths); From b61b182574e8bd500a824f92199336b55d61e8e0 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Thu, 9 Jul 2026 13:13:26 +0200 Subject: [PATCH 6/6] better error message --- src/views.rs | 90 +++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 82 insertions(+), 8 deletions(-) diff --git a/src/views.rs b/src/views.rs index ccbc9dd..2e9c0e3 100644 --- a/src/views.rs +++ b/src/views.rs @@ -961,7 +961,8 @@ async fn push(base: BaseArgs, args: ViewsPushArgs) -> Result<()> { "Pushing custom views...", functions_api::insert_functions(&auth_ctx.client, &events), ) - .await? + .await + .map_err(|err| anyhow!(format_views_insert_error(&prepared, args.if_exists, &err)))? .ignored_entries .unwrap_or(0); @@ -979,16 +980,27 @@ async fn push(base: BaseArgs, args: ViewsPushArgs) -> Result<()> { eprintln!( "{} Pushed {} custom view(s)", - dialoguer::console::style("✓").green(), - prepared.len().saturating_sub(ignored) + dialoguer::console::style("Success:").green(), + pushed.len() ); - for view in pushed { - match view.url { - Some(url) => println!("{} ({}) {}", view.name, view.slug, url), - None => println!("{} ({})", view.name, view.slug), + for view in &pushed { + let mut details = format!( + "{} {} ({})", + view.view_type.label(), + view.name, + view.project_name + ); + if let Some(dataset_id) = &view.dataset_id { + write!(details, ", dataset {dataset_id}").ok(); + } + if let Some(url) = &view.url { + write!(details, ": {url}").ok(); } + eprintln!(" {}", details); + } + if ignored > 0 { + eprintln!(" Ignored {} existing custom view(s)", ignored); } - Ok(()) } @@ -2390,6 +2402,41 @@ fn build_insert_event(view: &PreparedView, if_exists: IfExistsMode) -> Value { Value::Object(object) } +fn format_views_insert_error( + views: &[PreparedView], + if_exists: IfExistsMode, + err: &anyhow::Error, +) -> String { + let view_list = views + .iter() + .map(|view| { + format!( + "{} '{}' in project '{}'", + view.entry.view_type.label(), + view.entry.slug, + view.project.name + ) + }) + .collect::>() + .join(", "); + let view_list = if view_list.is_empty() { + "selected custom views".to_string() + } else { + view_list + }; + let details = format!("{err:#}"); + let retry_hint = if if_exists == IfExistsMode::Error { + " If you are updating an existing custom view, rerun with `bt views push --if-exists replace`." + } else { + "" + }; + + format!( + "failed to push custom views ({view_list}) with --if-exists {}.{retry_hint} Server response: {details}", + if_exists.as_str() + ) +} + async fn resolve_pushed_views( client: &ApiClient, app_url: &str, @@ -3264,6 +3311,33 @@ export default customTraceView({ name: "Test View", slug: "test-view" }, ({ span assert!(event.get("tags").is_none()); } + #[test] + fn formats_actionable_custom_view_insert_error() { + let view = PreparedView { + source_file: "test.view.tsx".to_string(), + entry: ViewManifestEntry { + view_type: ViewType::Trace, + name: "Test View".to_string(), + slug: "test-view".to_string(), + code: "module.exports = function View() {}".to_string(), + project_id: None, + project_name: None, + dataset_id: None, + dataset_name: None, + }, + project: test_project(), + dataset: None, + }; + let err = anyhow!("request failed (400 Bad Request): slug already exists"); + + let message = format_views_insert_error(&[view], IfExistsMode::Error, &err); + + assert!(message.contains("trace 'test-view' in project 'test-project'")); + assert!(message.contains("--if-exists error")); + assert!(message.contains("bt views push --if-exists replace")); + assert!(message.contains("slug already exists")); + } + #[test] fn dataset_insert_event_sets_origin() { let view = PreparedView {