diff --git a/.github/workflows/local.yml b/.github/workflows/local.yml new file mode 100644 index 000000000..2a90a09ac --- /dev/null +++ b/.github/workflows/local.yml @@ -0,0 +1,59 @@ +name: Local E2E + +on: + pull_request: + paths: + - "apps/local/**" + - "packages/cli/src/commands/local/**" + - "packages/cli/test/commands/local/**" + - "packages/cli/test/e2e/local.test.ts" + - "pnpm-lock.yaml" + - ".github/workflows/local.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + local: + name: Local (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + binary: sentry-linux-x64 + - os: macos-latest + binary: sentry-darwin-arm64 + - os: windows-latest + binary: sentry-windows-x64.exe + env: + SENTRY_CLIENT_ID: test-client-id + SENTRY_CLI_NO_TELEMETRY: "1" + FOSSILIZE_SIGN: "n" + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: "22.23.1" + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Viewer integration tests + run: pnpm --filter local test + - name: Bundle the CLI + run: pnpm --filter sentry bundle + - name: NPM daemon E2E + env: + SENTRY_CLI_BINARY: ${{ github.workspace }}/packages/cli/dist/bin.cjs + run: pnpm --filter sentry exec vitest run test/e2e/local.test.ts + - name: Build native CLI + run: pnpm --filter sentry build + - name: Native daemon E2E + env: + SENTRY_CLI_BINARY: ${{ github.workspace }}/packages/cli/dist-bin/${{ matrix.binary }} + run: pnpm --filter sentry exec vitest run test/e2e/local.test.ts diff --git a/AGENTS.md b/AGENTS.md index 62e319c6a..73ded3708 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,8 @@ Toolkit contains the Sentry CLI, the Sentry MCP server, and shared code. Product ``` toolkit/ ├── apps/ -│ └── cli-docs/ # CLI documentation site and generated command pages +│ ├── cli-docs/ # CLI documentation site and generated command pages +│ └── local/ # Sentry Local browser viewer ├── packages/ │ ├── cli/ # Sentry CLI (see packages/cli/AGENTS.md) │ ├── toolkit-core/ # Shared CLI/MCP primitives diff --git a/apps/cli-docs/src/content/docs/contributing.md b/apps/cli-docs/src/content/docs/contributing.md index 21665597c..12f0031d4 100644 --- a/apps/cli-docs/src/content/docs/contributing.md +++ b/apps/cli-docs/src/content/docs/contributing.md @@ -69,7 +69,7 @@ toolkit/ │ │ │ ├── event/ # list, send, view │ │ │ ├── feedback/ # list, resolve, spam, unresolve, view │ │ │ ├── issue/ # archive, events, explain, link, list, merge, plan, resolve, unlink, unresolve, view -│ │ │ ├── local/ # run, serve +│ │ │ ├── local/ # close, create, list, reset, restart, retention, run, serve, start, status, stop, view │ │ │ ├── log/ # list, view │ │ │ ├── monitor/ # list, run │ │ │ ├── org/ # list, view diff --git a/apps/cli-docs/src/fragments/commands/local.md b/apps/cli-docs/src/fragments/commands/local.md index 92a571f8f..e1097e51f 100644 --- a/apps/cli-docs/src/fragments/commands/local.md +++ b/apps/cli-docs/src/fragments/commands/local.md @@ -69,7 +69,7 @@ sentry local --open sentry local run --open -- npm run dev ``` -The `--open` flag requires a loopback `--host` (localhost, 127.0.0.1, or ::1). The UI is read-only — it reads the SSE stream but cannot ingest or clear data. Session data stays in memory for the duration of the server; nothing is sent to sentry.io unless a DSN is configured. +The `--open` flag requires a loopback `--host` (localhost, 127.0.0.1, or ::1). The hosted UI reads the SSE stream without changing retained receiver data. A loopback-served viewer can clear a receiver and reset or close a scoped daemon session. Session data stays in memory for the duration of the server; nothing is sent to sentry.io unless a DSN is configured. ## Endpoints @@ -185,3 +185,27 @@ curl -i http://127.0.0.1:3030/api/broken The final request intentionally returns HTTP 500. The fixture is for local experimentation only; do not run it with production credentials. + +## Daemon-owned sessions + +Use `--session ` to capture into the shared loopback daemon and retain data +after your foreground command exits. Sessions keep up to 500 envelopes for three +hours after closing and subsequent ingestion. Stopping or restarting the daemon +clears all sessions. + +```bash +sentry local run --session web -- npm run dev +sentry local serve --session worker +sentry local session list --json +sentry local event list web --json +sentry local event view web --json +sentry local envelope list web --json +sentry local session reset web +sentry local session close web +sentry local daemon status +sentry local daemon stop +``` + +`local session create --json` returns separate SDK ingest and viewer read +capability URLs. Keep these URLs private. `--clientId ` makes retried session +creation idempotent. Use the session ID when multiple sessions share a label. diff --git a/apps/local/.gitignore b/apps/local/.gitignore new file mode 100644 index 000000000..a547bf36d --- /dev/null +++ b/apps/local/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/apps/local/.oxlintrc.json b/apps/local/.oxlintrc.json new file mode 100644 index 000000000..6fa991dad --- /dev/null +++ b/apps/local/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/apps/local/components.json b/apps/local/components.json new file mode 100644 index 000000000..8bfc737f9 --- /dev/null +++ b/apps/local/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/apps/local/index.html b/apps/local/index.html new file mode 100644 index 000000000..cbe71556e --- /dev/null +++ b/apps/local/index.html @@ -0,0 +1,14 @@ + + + + + + + + Sentry CLI + + +
+ + + diff --git a/apps/local/package.json b/apps/local/package.json new file mode 100644 index 000000000..adc9a7e19 --- /dev/null +++ b/apps/local/package.json @@ -0,0 +1,55 @@ +{ + "name": "local", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "oxlint", + "preview": "vite preview", + "test": "vitest run", + "test:integration": "vitest run src/App.integration.test.tsx", + "typecheck": "tsc -b" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "dependencies": { + "@radix-ui/react-dialog": "^1.1.23", + "@radix-ui/react-slot": "^1.3.3", + "@shikijs/core": "^3.23.0", + "@shikijs/engine-javascript": "^3.23.0", + "@shikijs/langs": "^3.23.0", + "@shikijs/themes": "^3.23.0", + "@tailwindcss/vite": "^4.3.3", + "@uidotdev/usehooks": "^2.4.1", + "@vercel/analytics": "^2.0.1", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "lucide-react": "^1.43.0", + "next-themes": "^0.4.6", + "nuqs": "^2.10.1", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router": "^7.18.3", + "tailwind-merge": "^3.6.0", + "tailwindcss": "^4.3.3", + "tslib": "^2.8.1" + }, + "devDependencies": { + "@spotlightjs/spotlight": "^4.11.7", + "@testing-library/react": "^16.3.2", + "@types/node": "^24.13.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^6.1.0", + "eventsource": "^4.1.0", + "jsdom": "^28.1.0", + "oxlint": "^1.79.0", + "typescript": "catalog:", + "vite": "^8.2.2", + "vitest": "^4.1.9" + } +} diff --git a/apps/local/public/favicon.svg b/apps/local/public/favicon.svg new file mode 100644 index 000000000..7ad47f504 --- /dev/null +++ b/apps/local/public/favicon.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/local/public/sentry-cli-light.svg b/apps/local/public/sentry-cli-light.svg new file mode 100644 index 000000000..e124651b6 --- /dev/null +++ b/apps/local/public/sentry-cli-light.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/local/public/sentry-cli.svg b/apps/local/public/sentry-cli.svg new file mode 100644 index 000000000..74e2b4731 --- /dev/null +++ b/apps/local/public/sentry-cli.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/local/src/App.integration.test.tsx b/apps/local/src/App.integration.test.tsx new file mode 100644 index 000000000..1a1ec5c01 --- /dev/null +++ b/apps/local/src/App.integration.test.tsx @@ -0,0 +1,1947 @@ +import { createSpotlightBuffer } from "@spotlightjs/spotlight/sdk"; +import { EventSource as NodeEventSource } from "eventsource"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { NuqsAdapter } from "nuqs/adapters/react"; +import { NuqsTestingAdapter } from "nuqs/adapters/testing"; +import { BrowserRouter } from "react-router"; +import { ThemeProvider } from "@/components/theme-provider.tsx"; +import { createLocalRetentionBuffer } from "../../../packages/cli/src/commands/local/retention-buffer.ts"; +import App from "./App.tsx"; +import { LocalWorkspaceRoute } from "./routes/local-workspace-route.tsx"; +import { + REMOTE_STREAM_STORAGE_KEY, + STREAM_STORAGE_KEY, +} from "./lib/spotlight.ts"; +import { + buildApp, + tryListen, +} from "../../../packages/cli/src/commands/local/server.ts"; + +const SENTRY_CONTENT_TYPE = "application/x-sentry-envelope"; + +type EnvelopeOptions = { + level?: string; + operation?: string; + spanId?: string; + spans?: unknown[]; + statusCode?: number; + startTimestamp?: number; + traceId?: string; + type?: string; +}; + +function envelope(transaction: string, options: EnvelopeOptions = {}): string { + return [ + JSON.stringify({ event_id: crypto.randomUUID() }), + JSON.stringify({ type: options.type ?? "transaction" }), + JSON.stringify({ + transaction, + timestamp: 1_700_000_000, + ...(options.startTimestamp + ? { start_timestamp: options.startTimestamp } + : {}), + ...(options.level ? { level: options.level } : {}), + ...(options.traceId || options.operation + ? { + contexts: { + trace: { + ...(options.traceId ? { trace_id: options.traceId } : {}), + ...(options.spanId ? { span_id: options.spanId } : {}), + ...(options.operation ? { op: options.operation } : {}), + }, + }, + } + : {}), + ...(options.statusCode + ? { request: { status_code: options.statusCode } } + : {}), + ...(options.spans ? { spans: options.spans } : {}), + }), + "", + ].join("\n"); +} + +async function startReceiver() { + return tryListen(buildApp(createSpotlightBuffer(10)), 0, "127.0.0.1"); +} + +async function stopReceiver( + server: Awaited>["server"], +) { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +} + +async function sendEnvelope( + port: number, + transaction: string, + options?: EnvelopeOptions, +) { + const response = await fetch(`http://127.0.0.1:${port}/stream`, { + method: "POST", + headers: { "Content-Type": SENTRY_CONTENT_TYPE }, + body: envelope(transaction, options), + }); + expect(response.status).toBe(204); +} + +function renderViewer(port: number, path = "/") { + window.history.replaceState( + null, + "", + `${path}#stream=${encodeURIComponent(`http://127.0.0.1:${port}/stream`)}`, + ); + return render( + + + + + + + + + , + ); +} + +function renderBareViewer() { + window.history.replaceState(null, "", "/"); + return render( + + + + + + + + + , + ); +} + +class OpeningEventSource extends EventTarget { + onopen: ((event: Event) => void) | null = null; + onerror: ((event: Event) => void) | null = null; + + constructor(_url: string) { + super(); + queueMicrotask(() => this.onopen?.(new Event("open"))); + } + + close() {} +} + +class ControllableEventSource extends EventTarget { + static instances: ControllableEventSource[] = []; + onopen: ((event: Event) => void) | null = null; + onerror: ((event: Event) => void) | null = null; + closed = false; + + constructor(_url: string) { + super(); + ControllableEventSource.instances.push(this); + } + + close() { + this.closed = true; + } +} + +describe("local receiver to viewer integration", () => { + beforeEach(() => { + // Nuqs shares pending URL updates across mounts. Reset them between tests + // while keeping the real browser adapter for history integration coverage. + render({null}).unmount(); + window.localStorage.clear(); + window.sessionStorage.clear(); + Object.defineProperty(document, "execCommand", { + configurable: true, + value: vi.fn(() => true), + }); + vi.stubGlobal("EventSource", NodeEventSource); + vi.stubGlobal("matchMedia", () => ({ + matches: false, + media: "", + onchange: null, + addListener: () => undefined, + removeListener: () => undefined, + addEventListener: () => undefined, + removeEventListener: () => undefined, + dispatchEvent: () => false, + })); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + window.history.replaceState(null, "", "/"); + }); + + test("keeps the selected live transaction open in the detail pane", async () => { + const { server, port } = await startReceiver(); + + try { + renderViewer(port); + await screen.findByText("Connected to local receiver"); + + const shell = screen.getByTestId("app-shell"); + expect(shell.className).not.toContain("px-"); + expect(shell.className).not.toContain("py-"); + expect(screen.queryByRole("banner")).toBeNull(); + + await sendEnvelope(port, "GET /live"); + await screen.findByLabelText("View transaction event"); + await sendEnvelope(port, "GET /live-2"); + + await waitFor(() => { + expect(screen.getAllByLabelText("View transaction event")).toHaveLength( + 2, + ); + }); + const eventList = screen.getByTestId("event-list"); + expect(eventList.className).toContain("flex-1"); + expect(eventList.className).not.toContain("max-h-[42rem]"); + expect( + screen.getByRole("complementary", { name: "Live Activity" }), + ).not.toBeNull(); + const detail = screen.getByTestId("event-detail"); + expect(detail.textContent).toContain("GET /live"); + expect(detail.textContent).not.toContain("GET /live-2"); + expect(detail.textContent).not.toContain("GETGET /live"); + + const events = screen.getAllByLabelText("View transaction event"); + fireEvent.click(events[1]!); + + await waitFor(() => { + expect(screen.getByTestId("event-detail").textContent).toContain( + "GET /live-2", + ); + }); + expect(screen.getByRole("tab", { name: "Overview" })).not.toBeNull(); + fireEvent.click(screen.getByRole("tab", { name: "JSON" })); + const code = screen.getByTestId("highlighted-json"); + expect(code.textContent).toContain("GET /live-2"); + fireEvent.click(screen.getByRole("button", { name: "Copy JSON" })); + expect( + await screen.findByRole("status", { name: "JSON copied" }), + ).not.toBeNull(); + await waitFor(() => { + expect( + screen.getByTestId("highlighted-json").querySelector(".shiki"), + ).not.toBeNull(); + }); + fireEvent.click(events[0]!); + fireEvent.click(screen.getByRole("tab", { name: "JSON" })); + expect( + screen.getByRole("button", { name: "Copy JSON" }).textContent, + ).toContain("Copy JSON"); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("keeps direct receiver actions in a compact sidebar utility bar", async () => { + const { server, port } = await startReceiver(); + + try { + renderViewer(port); + await screen.findByText("Connected to local receiver"); + await sendEnvelope(port, "GET /dock"); + await screen.findByLabelText("View transaction event"); + + const sidebar = screen.getByLabelText("Workspace navigation"); + const utilityBar = sidebar.querySelector( + '[data-testid="sidebar-utility-bar"]', + ); + expect(utilityBar).not.toBeNull(); + expect(utilityBar?.textContent).toContain("Connected"); + expect( + utilityBar?.querySelector('button[aria-label="Search events"]'), + ).not.toBeNull(); + expect( + utilityBar + ?.querySelector('[role="status"]') + ?.getAttribute("aria-label"), + ).toBe("Connected to local receiver"); + expect( + sidebar.querySelector( + 'button[aria-label="Change receiver connection"]', + ), + ).not.toBeNull(); + expect( + sidebar.querySelector('button[aria-label="Clear events"]'), + ).not.toBeNull(); + expect(screen.queryByRole("banner")).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Collapse sidebar" })); + expect( + screen.getByRole("button", { name: "Open live activity view" }) + .className, + ).toContain("justify-center"); + expect( + utilityBar?.querySelector('button[aria-label="Search events"]') + ?.className, + ).toContain("mx-auto"); + + fireEvent.click( + screen.getByRole("button", { name: "Change receiver connection" }), + ); + expect( + await screen.findByRole("heading", { name: "Connect a receiver" }), + ).not.toBeNull(); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("offers a useful connection landing for a bare viewer visit", () => { + renderBareViewer(); + + expect(screen.getByAltText("Sentry CLI")).not.toBeNull(); + expect(screen.getByRole("banner").textContent).not.toContain( + "Receiver setup", + ); + expect( + screen.queryByRole("searchbox", { name: "Search events" }), + ).toBeNull(); + expect(screen.queryByLabelText("Workspace navigation")).toBeNull(); + expect(screen.getByText("Looking for Sentry Local")).not.toBeNull(); + expect( + screen.getByRole("status", { name: "Checking receiver" }), + ).not.toBeNull(); + expect(screen.queryByText("Advanced connection")).toBeNull(); + const endpoint = screen.getByLabelText( + "Receiver endpoint", + ) as HTMLInputElement; + expect(endpoint.value).toBe("http://localhost:8969/stream"); + const connectButton = screen.getByRole("button", { + name: "Connect", + }) as HTMLButtonElement; + expect(connectButton.disabled).toBe(false); + expect(connectButton.getAttribute("aria-busy")).toBe("false"); + expect( + screen.getByText( + "Defaults to your local receiver. You can paste another local or HTTPS stream above.", + ), + ).not.toBeNull(); + expect( + screen.getByRole("button", { name: "Copy local serve command" }), + ).not.toBeNull(); + }); + + test("renders every Explorer view from a receiver-backed telemetry session", async () => { + const { server, port } = await startReceiver(); + + try { + renderViewer(port); + await screen.findByText("Connected to local receiver"); + await sendEnvelope(port, "GET /trace"); + await sendEnvelope(port, "GET /error", { type: "event", level: "error" }); + await sendEnvelope(port, "GET /logs", { type: "log" }); + await sendEnvelope(port, "GET /feedback", { type: "user_report" }); + await sendEnvelope(port, "GET /profile", { type: "profile" }); + await sendEnvelope(port, "GET /sdk", { type: "client_report" }); + await sendEnvelope(port, "GET /ai", { operation: "ai.generate" }); + + const explorerViews = [ + ["Open live activity view", "View transaction event"], + ["Open errors view", "View event event"], + ["Open logs view", "View log event"], + ["Open feedback view", "View user_report event"], + ["Open envelopes view", "View envelope event"], + ["Open profiles view", "View profile event"], + ["Open sessions & sdks view", "View client_report event"], + ["Open ai view", "View transaction event"], + ] as const; + + for (const [view, event] of explorerViews) { + fireEvent.click(screen.getByRole("button", { name: view })); + expect((await screen.findAllByLabelText(event)).length).toBeGreaterThan( + 0, + ); + } + + fireEvent.click(screen.getByRole("button", { name: "Open traces view" })); + expect( + await screen.findByRole("region", { name: "Trace waterfall" }), + ).not.toBeNull(); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("connects a bare viewer to a custom loopback receiver and saves it after opening", async () => { + const { server, port } = await startReceiver(); + + try { + renderBareViewer(); + fireEvent.change(screen.getByLabelText("Receiver endpoint"), { + target: { value: `http://127.0.0.1:${port}/stream` }, + }); + fireEvent.click(screen.getByRole("button", { name: "Connect" })); + + await screen.findByText("Connected to local receiver"); + expect(window.localStorage.getItem("sentry.local.stream-url")).toBe( + `http://127.0.0.1:${port}/stream`, + ); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("keeps a successfully opened remote receiver only for this browser session", async () => { + vi.stubGlobal("EventSource", OpeningEventSource); + renderBareViewer(); + await screen.findByText("Connected to local receiver"); + window.localStorage.clear(); + fireEvent.click(screen.getByRole("button", { name: "Change receiver" })); + + fireEvent.change(screen.getByLabelText("Receiver endpoint"), { + target: { value: "https://receiver.example/stream?token=abc" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Connect" })); + + await waitFor(() => { + expect(window.sessionStorage.getItem(REMOTE_STREAM_STORAGE_KEY)).toBe( + "https://receiver.example/stream?token=abc", + ); + }); + expect(window.localStorage.getItem(STREAM_STORAGE_KEY)).toBeNull(); + }); + + test("keeps endpoint validation visible after a failed receiver has been stopped", async () => { + ControllableEventSource.instances = []; + vi.stubGlobal("EventSource", ControllableEventSource); + renderBareViewer(); + + const source = ControllableEventSource.instances[0]; + expect(source).toBeDefined(); + await act(async () => source?.onerror?.(new Event("error"))); + await screen.findByRole("alert", { name: "Receiver connection error" }); + + fireEvent.change(screen.getByLabelText("Receiver endpoint"), { + target: { value: "http://receiver.example/stream" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Connect" })); + expect(screen.getByRole("alert").textContent).toContain( + "Enter a loopback stream or an HTTPS remote stream", + ); + + await act(async () => source?.onerror?.(new Event("error"))); + expect(screen.getByRole("alert").textContent).toContain( + "Enter a loopback stream or an HTTPS remote stream", + ); + }); + + test("stops an unanswered receiver attempt after ten seconds", async () => { + ControllableEventSource.instances = []; + vi.stubGlobal("EventSource", ControllableEventSource); + vi.useFakeTimers(); + + try { + renderBareViewer(); + + const initialSource = ControllableEventSource.instances[0]; + expect(initialSource).toBeDefined(); + await act(async () => initialSource?.onerror?.(new Event("error"))); + fireEvent.change(screen.getByLabelText("Receiver endpoint"), { + target: { value: "http://127.0.0.1:8970/stream" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Connect" })); + + const source = ControllableEventSource.instances[1]; + expect(source).toBeDefined(); + const connectingButton = screen.getByRole("button", { + name: "Connecting to receiver", + }) as HTMLButtonElement; + expect(connectingButton.disabled).toBe(true); + expect(connectingButton.getAttribute("aria-busy")).toBe("true"); + + await act(async () => { + vi.advanceTimersByTime(10_000); + }); + + expect(source?.closed).toBe(true); + expect( + screen.getByRole("alert", { name: "Receiver connection error" }) + .textContent, + ).toContain("Connection timed out after 10 seconds"); + expect( + (screen.getByRole("button", { name: "Connect" }) as HTMLButtonElement) + .disabled, + ).toBe(false); + } finally { + cleanup(); + vi.useRealTimers(); + } + }); + + test("keeps receiver selection open when an event arrives during editing", async () => { + ControllableEventSource.instances = []; + vi.stubGlobal("EventSource", ControllableEventSource); + renderBareViewer(); + + const source = ControllableEventSource.instances[0]; + expect(source).toBeDefined(); + await act(async () => source?.onopen?.(new Event("open"))); + await screen.findByText("Connected to local receiver"); + fireEvent.click(screen.getByRole("button", { name: "Change receiver" })); + + await act(async () => { + source?.dispatchEvent( + new MessageEvent(SENTRY_CONTENT_TYPE, { + data: JSON.stringify([ + {}, + [ + [ + { type: "transaction" }, + { transaction: "GET /arrived-while-editing" }, + ], + ], + ]), + }), + ); + }); + + expect(screen.getByLabelText("Receiver endpoint")).not.toBeNull(); + }); + + test("keeps workspace navigation visible while reconnecting", async () => { + ControllableEventSource.instances = []; + vi.stubGlobal("EventSource", ControllableEventSource); + renderBareViewer(); + + const source = ControllableEventSource.instances[0]; + expect(source).toBeDefined(); + await act(async () => source?.onopen?.(new Event("open"))); + await screen.findByText("Connected to local receiver"); + + fireEvent.click( + screen.getByRole("button", { name: "Change receiver connection" }), + ); + fireEvent.change(screen.getByLabelText("Receiver endpoint"), { + target: { value: "http://127.0.0.1:8970/stream" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Connect" })); + + expect(screen.getByLabelText("Workspace navigation")).not.toBeNull(); + expect( + screen.getByRole("button", { name: "Connecting to receiver" }), + ).not.toBeNull(); + }); + + test("keeps a healthy receiver open after an invalid replacement endpoint", async () => { + ControllableEventSource.instances = []; + vi.stubGlobal("EventSource", ControllableEventSource); + renderBareViewer(); + + const source = ControllableEventSource.instances[0]; + expect(source).toBeDefined(); + await act(async () => source?.onopen?.(new Event("open"))); + await screen.findByText("Connected to local receiver"); + fireEvent.click(screen.getByRole("button", { name: "Change receiver" })); + fireEvent.change(screen.getByLabelText("Receiver endpoint"), { + target: { value: "http://receiver.example/stream" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Connect" })); + + expect(source?.closed).toBe(false); + expect( + screen.getByRole("status", { name: "Connected to local receiver" }), + ).not.toBeNull(); + }); + + test("replays an envelope buffered before the viewer opens", async () => { + const { server, port } = await startReceiver(); + + try { + await sendEnvelope(port, "GET /buffered"); + renderViewer(port); + + await screen.findByText("Connected to local receiver"); + expect((await screen.findByTestId("event-detail")).textContent).toContain( + "GET /buffered", + ); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("does not mark the receiver replay as new events", async () => { + const { server, port } = await startReceiver(); + + try { + await sendEnvelope(port, "GET /buffered-first"); + await sendEnvelope(port, "GET /buffered-second"); + renderViewer(port); + + await waitFor(() => { + expect(screen.getAllByLabelText("View transaction event")).toHaveLength( + 2, + ); + }); + expect( + screen.queryByRole("button", { name: /View .* new event/ }), + ).toBeNull(); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("keeps Live Activity unfiltered and uses the sidebar for event classes", async () => { + const { server, port } = await startReceiver(); + + try { + renderViewer(port); + await screen.findByText("Connected to local receiver"); + await sendEnvelope(port, "GET /healthy"); + await sendEnvelope(port, "GET /broken", { + type: "event", + level: "error", + }); + + await screen.findByLabelText("View transaction event"); + await screen.findByLabelText("View event event"); + + expect(screen.queryByLabelText("Filter events")).toBeNull(); + expect( + screen.getByLabelText("View transaction event").textContent, + ).toContain("/healthy"); + expect(screen.getByLabelText("View event event").textContent).toContain( + "/broken", + ); + + fireEvent.click(screen.getByRole("button", { name: "Open errors view" })); + expect(screen.queryByLabelText("View transaction event")).toBeNull(); + expect(screen.getByLabelText("View event event").textContent).toContain( + "/broken", + ); + + await sendEnvelope(port, "GET /another-healthy"); + await waitFor(() => { + expect( + screen.getByRole("button", { name: "Open live activity view" }) + .textContent, + ).toContain("3"); + }); + expect( + screen.queryByRole("button", { name: /View 1 new event/ }), + ).toBeNull(); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("uses the collapsible workspace sidebar to focus an observability view", async () => { + const { server, port } = await startReceiver(); + + try { + renderViewer(port); + await screen.findByText("Connected to local receiver"); + await sendEnvelope(port, "GET /healthy"); + await sendEnvelope(port, "GET /broken", { + type: "event", + level: "error", + }); + + await screen.findByLabelText("View event event"); + expect( + screen.getByRole("button", { name: "Open live activity view" }), + ).not.toBeNull(); + expect( + screen.getByRole("button", { name: "Open errors view" }).textContent, + ).toContain("1"); + + fireEvent.click(screen.getByRole("button", { name: "Open errors view" })); + expect( + screen + .getByRole("button", { name: "Open errors view" }) + .getAttribute("aria-current"), + ).toBe("page"); + expect(screen.getAllByLabelText(/View .* event/)).toHaveLength(1); + + fireEvent.click(screen.getByRole("button", { name: "Collapse sidebar" })); + expect( + screen.getByRole("button", { name: "Expand sidebar" }), + ).not.toBeNull(); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("restores the collapsed sidebar after a refresh", async () => { + const { server, port } = await startReceiver(); + + try { + renderViewer(port); + await screen.findByText("Connected to local receiver"); + fireEvent.click(screen.getByRole("button", { name: "Collapse sidebar" })); + + expect( + window.localStorage.getItem("sentry.local.sidebar-collapsed"), + ).toBe("true"); + + cleanup(); + renderViewer(port); + + expect( + await screen.findByRole("button", { name: "Expand sidebar" }), + ).not.toBeNull(); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("keeps raw received envelopes available outside the live event feed", async () => { + const { server, port } = await startReceiver(); + + try { + renderViewer(port); + await screen.findByText("Connected to local receiver"); + await sendEnvelope(port, "GET /raw-envelope"); + await screen.findByLabelText("View transaction event"); + + fireEvent.click( + screen.getByRole("button", { name: "Open envelopes view" }), + ); + + expect( + screen.getByRole("button", { name: "Open envelopes view" }).textContent, + ).toContain("1"); + fireEvent.click(screen.getByLabelText("View envelope event")); + expect( + screen.getByRole("heading", { name: "Raw envelope" }), + ).not.toBeNull(); + expect(screen.getByTestId("event-detail").textContent).toContain( + "Envelope", + ); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("offers new raw envelopes after the Envelopes view has been read", async () => { + const { server, port } = await startReceiver(); + + try { + renderViewer(port); + await screen.findByText("Connected to local receiver"); + await sendEnvelope(port, "GET /first-envelope"); + await screen.findByLabelText("View transaction event"); + + fireEvent.click( + screen.getByRole("button", { name: "Open envelopes view" }), + ); + fireEvent.click(await screen.findByLabelText("View envelope event")); + await sendEnvelope(port, "GET /second-envelope"); + + expect( + await screen.findByRole("button", { name: "View 1 new envelope" }), + ).not.toBeNull(); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("enables clearing when only raw envelopes are retained", async () => { + ControllableEventSource.instances = []; + vi.stubGlobal("EventSource", ControllableEventSource); + renderBareViewer(); + + const source = ControllableEventSource.instances[0]; + expect(source).toBeDefined(); + await act(async () => source?.onopen?.(new Event("open"))); + await screen.findByText("Connected to local receiver"); + + await act(async () => { + source?.dispatchEvent( + new MessageEvent(SENTRY_CONTENT_TYPE, { + data: JSON.stringify([{}, []]), + lastEventId: "raw-only-envelope", + }), + ); + }); + + const clearButton = screen.getByRole("button", { + name: "Clear events", + }) as HTMLButtonElement; + await waitFor(() => expect(clearButton.disabled).toBe(false)); + fireEvent.click( + screen.getByRole("button", { name: "Open envelopes view" }), + ); + expect(await screen.findByLabelText("View envelope event")).not.toBeNull(); + fireEvent.click(clearButton); + + await waitFor(() => expect(clearButton.disabled).toBe(true)); + }); + + test("preserves undecodable raw envelopes and clears their stale alert with the feed", async () => { + ControllableEventSource.instances = []; + vi.stubGlobal("EventSource", ControllableEventSource); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response(null, { status: 204 })), + ); + renderBareViewer(); + const source = ControllableEventSource.instances[0]; + await act(async () => { + source?.onopen?.(new Event("open")); + source?.dispatchEvent( + new MessageEvent(SENTRY_CONTENT_TYPE, { + data: "undecodable", + lastEventId: "raw-broken", + }), + ); + }); + await screen.findByText("Received an event that could not be decoded."); + const clear = screen.getByRole("button", { + name: "Clear events", + }) as HTMLButtonElement; + expect(clear.disabled).toBe(false); + fireEvent.click( + screen.getByRole("button", { name: "Open envelopes view" }), + ); + await screen.findByLabelText("View envelope event"); + fireEvent.click(clear); + await waitFor(() => + expect( + screen.queryByText("Received an event that could not be decoded."), + ).toBeNull(), + ); + expect(clear.disabled).toBe(true); + }); + + test("replays more than 500 retained envelopes and applies a real runtime update", async () => { + const retention = createLocalRetentionBuffer(1000); + const receiver = await tryListen( + buildApp(retention.buffer, { uiActions: true, retention }), + 0, + "127.0.0.1", + ); + try { + // Initial metadata must precede replay, otherwise the viewer's default clips history. + for (let index = 0; index < 501; index++) + await sendEnvelope(receiver.port, `retained-${index}`); + renderViewer(receiver.port, "/explorer/events"); + await screen.findByText( + /Retention limit: 1,000 envelopes · 501 displayed/, + {}, + { timeout: 10000 }, + ); + fireEvent.click(screen.getByText(/Retention limit: 1,000 envelopes/)); + fireEvent.change(screen.getByLabelText("Retention limit"), { + target: { value: "2000" }, + }); + fireEvent.click( + screen.getByRole("button", { name: "Apply retention limit" }), + ); + await screen.findByText( + /Retention limit: 2,000 envelopes · 501 displayed/, + ); + expect(retention.getSettings()).toEqual({ + maxEnvelopes: 2000, + retainedEnvelopes: 501, + }); + await sendEnvelope(receiver.port, "after-growth"); + await screen.findByText( + /Retention limit: 2,000 envelopes · 502 displayed/, + ); + } finally { + cleanup(); + await stopReceiver(receiver.server); + } + }, 20000); + + test("updates live retention without clearing events or replacing the stream", async () => { + ControllableEventSource.instances = []; + vi.stubGlobal("EventSource", ControllableEventSource); + const fetchMock = vi + .fn() + .mockResolvedValue( + new Response( + JSON.stringify({ maxEnvelopes: 1000, retainedEnvelopes: 2 }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + vi.stubGlobal("fetch", fetchMock); + renderBareViewer(); + const source = ControllableEventSource.instances[0]; + await act(async () => source?.onopen?.(new Event("open"))); + await act(async () => { + source?.dispatchEvent( + new MessageEvent("sentry.local.retention", { + data: JSON.stringify({ maxEnvelopes: 500 }), + }), + ); + for (const id of ["one", "two"]) + source?.dispatchEvent( + new MessageEvent(SENTRY_CONTENT_TYPE, { + data: JSON.stringify([ + {}, + [[{ type: "event" }, { message: `retained ${id}` }]], + ]), + lastEventId: id, + }), + ); + }); + fireEvent.click(await screen.findByText(/Retention limit: 500 envelopes/)); + fireEvent.change(screen.getByLabelText("Retention limit"), { + target: { value: "1" }, + }); + fireEvent.click( + screen.getByRole("button", { name: "Apply retention limit" }), + ); + await screen.findByText( + /Cannot lower the limit below 2 displayed envelopes/, + ); + expect(fetchMock).not.toHaveBeenCalled(); + fireEvent.change(screen.getByLabelText("Retention limit"), { + target: { value: "1000" }, + }); + fireEvent.click( + screen.getByRole("button", { name: "Apply retention limit" }), + ); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + await act(async () => + source?.dispatchEvent( + new MessageEvent("sentry.local.retention", { + data: JSON.stringify({ maxEnvelopes: 1000 }), + }), + ), + ); + await screen.findByText(/Retention limit: 1,000 envelopes/); + expect( + screen.getAllByRole("button", { name: "View event event" }), + ).toHaveLength(2); + expect(source?.closed).toBe(false); + expect(ControllableEventSource.instances).toHaveLength(1); + }); + + test("requires reconnection before changing a disconnected receiver's retention", async () => { + ControllableEventSource.instances = []; + vi.stubGlobal("EventSource", ControllableEventSource); + renderBareViewer(); + const source = ControllableEventSource.instances[0]; + await act(async () => { + source?.onopen?.(new Event("open")); + source?.dispatchEvent( + new MessageEvent("sentry.local.retention", { + data: '{"maxEnvelopes":1000}', + }), + ); + source?.dispatchEvent( + new MessageEvent(SENTRY_CONTENT_TYPE, { + data: JSON.stringify([ + {}, + [[{ type: "event" }, { message: "kept" }]], + ]), + lastEventId: "kept", + }), + ); + source?.onerror?.(new Event("error")); + }); + fireEvent.click( + await screen.findByText(/Retention limit: 1,000 envelopes/), + ); + await screen.findByText("Reconnect to this receiver to change retention."); + expect( + screen.queryByRole("button", { name: "Apply retention limit" }), + ).toBeNull(); + expect( + screen.getByRole("button", { name: "View event event" }), + ).not.toBeNull(); + }); + + test.each([false, true])( + "ignores delayed retention responses after newer metadata (switch receiver: %s)", + async (switchReceiver) => { + ControllableEventSource.instances = []; + vi.stubGlobal("EventSource", ControllableEventSource); + let respond!: (response: Response) => void; + const pending = new Promise((resolve) => { + respond = resolve; + }); + const fetchMock = vi.fn().mockReturnValue(pending); + vi.stubGlobal("fetch", fetchMock); + renderBareViewer(); + let source = ControllableEventSource.instances[0]; + await act(async () => { + source?.onopen?.(new Event("open")); + source?.dispatchEvent( + new MessageEvent("sentry.local.retention", { + data: '{"maxEnvelopes":500}', + }), + ); + }); + fireEvent.click( + await screen.findByText(/Retention limit: 500 envelopes/), + ); + fireEvent.change(screen.getByLabelText("Retention limit"), { + target: { value: "1" }, + }); + fireEvent.click( + screen.getByRole("button", { name: "Apply retention limit" }), + ); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + if (switchReceiver) { + fireEvent.click( + screen.getByRole("button", { name: "Change receiver" }), + ); + fireEvent.change(screen.getByLabelText("Receiver endpoint"), { + target: { value: "http://127.0.0.1:8970/stream" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Connect" })); + source = ControllableEventSource.instances[1]; + } + await act(async () => { + source?.onopen?.(new Event("open")); + source?.dispatchEvent( + new MessageEvent("sentry.local.retention", { + data: '{"maxEnvelopes":1000}', + }), + ); + respond(new Response('{"maxEnvelopes":1}', { status: 200 })); + await pending; + }); + await screen.findByText(/Retention limit: 1,000 envelopes/); + await act(async () => { + for (const id of ["one", "two"]) + source?.dispatchEvent( + new MessageEvent(SENTRY_CONTENT_TYPE, { + data: JSON.stringify([ + {}, + [[{ type: "event" }, { message: id }]], + ]), + lastEventId: id, + }), + ); + }); + expect( + screen.getAllByRole("button", { name: "View event event" }), + ).toHaveLength(2); + }, + ); + + test.each([204, 403])( + "handles scoped reset HTTP %s and closes a telemetry session", + async (resetStatus) => { + ControllableEventSource.instances = []; + vi.stubGlobal("EventSource", ControllableEventSource); + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response(null, { status: resetStatus })) + .mockResolvedValue(new Response(null, { status: 204 })); + vi.stubGlobal("fetch", fetchMock); + renderBareViewer(); + + const initialSource = ControllableEventSource.instances[0]; + expect(initialSource).toBeDefined(); + await act(async () => initialSource?.onopen?.(new Event("open"))); + fireEvent.click(screen.getByRole("button", { name: "Change receiver" })); + + const streamUrl = + "http://127.0.0.1:8969/_local/v1/sessions/session-1/stream?cap=read-capability"; + fireEvent.change(screen.getByLabelText("Receiver endpoint"), { + target: { value: streamUrl }, + }); + fireEvent.click(screen.getByRole("button", { name: "Connect" })); + + const scopedSource = ControllableEventSource.instances[1]; + expect(scopedSource).toBeDefined(); + await act(async () => scopedSource?.onopen?.(new Event("open"))); + await act(async () => { + scopedSource?.dispatchEvent( + new MessageEvent(SENTRY_CONTENT_TYPE, { + data: JSON.stringify([{}, []]), + lastEventId: "scoped-envelope", + }), + ); + }); + + const clearButton = screen.getByRole("button", { name: "Clear events" }); + await waitFor(() => + expect((clearButton as HTMLButtonElement).disabled).toBe(false), + ); + fireEvent.click(clearButton); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + expect(String(fetchMock.mock.calls[0]?.[0])).toBe( + "http://127.0.0.1:8969/_local/v1/sessions/session-1/ui/reset?cap=read-capability", + ); + expect(fetchMock.mock.calls[0]?.[1]).toEqual({ method: "POST" }); + if (resetStatus !== 204) { + await screen.findByText( + "Cleared this viewer, but the receiver could not clear its retained session.", + ); + } + + fireEvent.click( + screen.getByRole("button", { name: "Close telemetry session" }), + ); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + expect(String(fetchMock.mock.calls[1]?.[0])).toBe( + "http://127.0.0.1:8969/_local/v1/sessions/session-1/ui/close?cap=read-capability", + ); + expect(fetchMock.mock.calls[1]?.[1]).toEqual({ method: "POST" }); + await waitFor(() => expect(scopedSource?.closed).toBe(true)); + await screen.findByRole("heading", { name: "Connect a receiver" }); + expect(screen.queryByText("Waiting for events")).toBeNull(); + expect(screen.getByLabelText("Receiver endpoint")).not.toBeNull(); + await waitFor(() => + expect( + screen.queryAllByLabelText("Connected to local receiver"), + ).toHaveLength(0), + ); + }, + ); + + test("keeps retained raw envelopes accessible after the receiver fails", async () => { + ControllableEventSource.instances = []; + vi.stubGlobal("EventSource", ControllableEventSource); + renderBareViewer(); + + const source = ControllableEventSource.instances[0]; + expect(source).toBeDefined(); + await act(async () => source?.onopen?.(new Event("open"))); + await screen.findByText("Connected to local receiver"); + + await act(async () => { + source?.dispatchEvent( + new MessageEvent(SENTRY_CONTENT_TYPE, { + data: JSON.stringify([{}, []]), + lastEventId: "raw-envelope-before-failure", + }), + ); + }); + fireEvent.click( + screen.getByRole("button", { name: "Open envelopes view" }), + ); + fireEvent.click(await screen.findByLabelText("View envelope event")); + + await act(async () => source?.onerror?.(new Event("error"))); + + expect(screen.getByLabelText("Workspace navigation")).not.toBeNull(); + expect( + screen.getByRole("heading", { name: "Raw envelope" }), + ).not.toBeNull(); + expect(screen.queryByLabelText("Receiver endpoint")).toBeNull(); + fireEvent.click( + screen.getAllByRole("button", { name: "Search events" })[0]!, + ); + fireEvent.change(screen.getByPlaceholderText("Search events and views"), { + target: { value: "unmatched-envelope" }, + }); + await waitFor(() => + expect(new URLSearchParams(window.location.search).get("q")).toBe( + "unmatched-envelope", + ), + ); + expect(screen.getByTestId("event-list").textContent).toContain( + "No matching envelopes", + ); + }); + + test.each(["failed", "closed"])( + "keeps retained search usable after the receiver is %s", + async (state) => { + ControllableEventSource.instances = []; + vi.stubGlobal("EventSource", ControllableEventSource); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response(null, { status: 204 })), + ); + renderBareViewer(); + await act(async () => + ControllableEventSource.instances[0]?.onopen?.(new Event("open")), + ); + fireEvent.click(screen.getByRole("button", { name: "Change receiver" })); + fireEvent.change(screen.getByLabelText("Receiver endpoint"), { + target: { + value: + "http://127.0.0.1:8969/_local/v1/sessions/retained/stream?cap=read", + }, + }); + fireEvent.click(screen.getByRole("button", { name: "Connect" })); + const source = ControllableEventSource.instances[1]; + await act(async () => { + source?.onopen?.(new Event("open")); + for (const transaction of ["GET /alpha", "GET /beta"]) { + source?.dispatchEvent( + new MessageEvent(SENTRY_CONTENT_TYPE, { + data: JSON.stringify([ + {}, + [[{ type: "transaction" }, { transaction }]], + ]), + lastEventId: transaction, + }), + ); + } + }); + await waitFor(() => + expect(screen.getAllByLabelText("View transaction event")).toHaveLength( + 2, + ), + ); + fireEvent.click( + screen.getAllByRole("button", { name: "Search events" })[0]!, + ); + fireEvent.change(screen.getByPlaceholderText("Search events and views"), { + target: { value: "alpha" }, + }); + await waitFor(() => + expect(new URLSearchParams(window.location.search).get("q")).toBe( + "alpha", + ), + ); + fireEvent.keyDown( + screen.getByPlaceholderText("Search events and views"), + { key: "Escape" }, + ); + if (state === "closed") { + fireEvent.click( + screen.getByRole("button", { name: "Close telemetry session" }), + ); + await waitFor(() => expect(source?.closed).toBe(true)); + } else { + await act(async () => source?.onerror?.(new Event("error"))); + } + fireEvent.click( + screen.getAllByRole("button", { name: "Search events" })[0]!, + ); + fireEvent.change(screen.getByPlaceholderText("Search events and views"), { + target: { value: "" }, + }); + await waitFor(() => { + expect(screen.getAllByLabelText("View transaction event")).toHaveLength( + 2, + ); + expect(new URLSearchParams(window.location.search).get("q")).toBeNull(); + }); + }, + ); + + test("distinguishes empty trace searches from a receiver with no traces", async () => { + const { server, port } = await startReceiver(); + try { + await sendEnvelope(port, "GET /alpha", { traceId: "trace-alpha" }); + renderViewer(port, "/traces?q=missing"); + await screen.findByText("Connected to local receiver"); + await screen.findByRole("heading", { name: "No matching traces" }); + expect(screen.queryByText("No traces captured")).toBeNull(); + expect( + screen.getByText( + "Try a different search term or clear the search to see everything in this view.", + ), + ).not.toBeNull(); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("omits redundant type pills from dedicated Errors and Envelopes lists", async () => { + const { server, port } = await startReceiver(); + + try { + renderViewer(port); + await screen.findByText("Connected to local receiver"); + await sendEnvelope(port, "GET /broken", { + type: "event", + level: "error", + }); + await screen.findByLabelText("View event event"); + + fireEvent.click(screen.getByRole("button", { name: "Open errors view" })); + expect( + screen.getByLabelText("View event event").textContent, + ).not.toContain("Error"); + + fireEvent.click( + screen.getByRole("button", { name: "Open envelopes view" }), + ); + expect( + screen.getByLabelText("View envelope event").textContent, + ).not.toContain("envelope"); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("marks an error entry in the mixed event feed", async () => { + const { server, port } = await startReceiver(); + + try { + renderViewer(port); + await screen.findByText("Connected to local receiver"); + await sendEnvelope(port, "GET /broken", { + type: "event", + level: "error", + }); + + expect( + (await screen.findByLabelText("View event event")).textContent, + ).toContain("Error"); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("selects an event from the active workspace instead of retaining a live event", async () => { + const { server, port } = await startReceiver(); + + try { + renderViewer(port); + await screen.findByText("Connected to local receiver"); + await sendEnvelope(port, "GET /healthy"); + await sendEnvelope(port, "GET /broken", { + type: "event", + level: "error", + }); + + await screen.findByLabelText("View event event"); + fireEvent.click(screen.getByRole("button", { name: "Open errors view" })); + + expect(screen.getByTestId("event-detail").textContent).toContain( + "/broken", + ); + expect(screen.getByTestId("event-detail").textContent).not.toContain( + "/healthy", + ); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("filters retained events from the global command search", async () => { + const { server, port } = await startReceiver(); + + try { + renderViewer(port); + await screen.findByText("Connected to local receiver"); + await sendEnvelope(port, "GET /customers/42"); + await sendEnvelope(port, "POST /orders"); + + await waitFor(() => { + expect(screen.getAllByLabelText("View transaction event")).toHaveLength( + 2, + ); + }); + fireEvent.click( + screen.getAllByRole("button", { name: "Search events" })[0]!, + ); + fireEvent.change(screen.getByPlaceholderText("Search events and views"), { + target: { value: "customers" }, + }); + + expect(screen.getAllByLabelText("View transaction event")).toHaveLength( + 2, + ); + expect(new URLSearchParams(window.location.search).get("q")).toBeNull(); + + await waitFor(() => { + expect(screen.getAllByLabelText("View transaction event")).toHaveLength( + 1, + ); + }); + expect(screen.getByTestId("event-list").textContent).toContain( + "/customers/42", + ); + expect(screen.getByTestId("event-list").textContent).not.toContain( + "/orders", + ); + await waitFor(() => { + expect(new URLSearchParams(window.location.search).get("q")).toBe( + "customers", + ); + }); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("keeps event detail within the active search filter", async () => { + const { server, port } = await startReceiver(); + try { + await sendEnvelope(port, "GET /unmatched-first"); + await sendEnvelope(port, "GET /matching-second"); + renderViewer(port, "/?q=matching-second"); + await screen.findByText("Connected to local receiver"); + await screen.findByLabelText("View transaction event"); + await waitFor(() => { + expect(screen.getByTestId("event-detail").textContent).toContain( + "/matching-second", + ); + expect(screen.getByTestId("event-detail").textContent).not.toContain( + "/unmatched-first", + ); + }); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("clearing events also clears the global search for subsequent events", async () => { + const { server, port } = await startReceiver(); + try { + await sendEnvelope(port, "GET /old-search"); + renderViewer(port, "/?q=old-search"); + await screen.findByText("Connected to local receiver"); + await screen.findByLabelText("View transaction event"); + fireEvent.click(screen.getByRole("button", { name: "Clear events" })); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 500)); + }); + expect(new URLSearchParams(window.location.search).get("q")).toBeNull(); + await sendEnvelope(port, "GET /new-event"); + await screen.findByLabelText("View transaction event"); + expect(screen.getByTestId("event-detail").textContent).toContain( + "/new-event", + ); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("filters trace groups with global search while retaining their full waterfall", async () => { + const { server, port } = await startReceiver(); + try { + await sendEnvelope(port, "GET /alpha", { traceId: "trace-alpha" }); + await sendEnvelope(port, "GET /beta", { + traceId: "trace-beta", + spanId: "beta-span", + }); + await sendEnvelope(port, "GET /related", { + traceId: "trace-beta", + spanId: "related-span", + }); + renderViewer(port, "/traces?q=beta"); + await screen.findByText("Connected to local receiver"); + await screen.findByRole("button", { name: "View trace GET /beta" }); + expect( + screen.queryByRole("button", { name: "View trace GET /alpha" }), + ).toBeNull(); + expect(screen.getByLabelText("Trace waterfall").textContent).toContain( + "/related", + ); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("switching receivers isolates telemetry and selection from the previous stream", async () => { + const first = await startReceiver(); + const second = await startReceiver(); + try { + await sendEnvelope(first.port, "GET /session-a"); + await sendEnvelope(second.port, "GET /session-b"); + renderViewer(first.port); + await screen.findByText("Connected to local receiver"); + await screen.findByLabelText("View transaction event"); + fireEvent.click( + screen.getByRole("button", { name: "Change receiver connection" }), + ); + fireEvent.click(screen.getByRole("button", { name: "Connect" })); + await screen.findByText("Connected to local receiver"); + expect(screen.getByTestId("event-list").textContent).toContain( + "/session-a", + ); + expect(screen.getAllByLabelText("View transaction event")).toHaveLength( + 1, + ); + fireEvent.click( + screen.getByRole("button", { name: "Change receiver connection" }), + ); + fireEvent.change(screen.getByLabelText("Receiver endpoint"), { + target: { value: `http://127.0.0.1:${second.port}/stream` }, + }); + fireEvent.click(screen.getByRole("button", { name: "Connect" })); + await waitFor(() => { + expect(screen.getByTestId("event-list").textContent).toContain( + "/session-b", + ); + expect(screen.getByTestId("event-list").textContent).not.toContain( + "/session-a", + ); + }); + expect(screen.getAllByLabelText("View transaction event")).toHaveLength( + 1, + ); + expect(screen.getByTestId("event-detail").textContent).toContain( + "/session-b", + ); + } finally { + cleanup(); + await stopReceiver(first.server); + await stopReceiver(second.server); + } + }); + + test("hydrates a shareable Explorer route and selected event from the URL", async () => { + const { server, port } = await startReceiver(); + + try { + await sendEnvelope(port, "GET /other-error", { + level: "error", + type: "event", + }); + await sendEnvelope(port, "GET /shared-error", { + level: "error", + type: "event", + }); + + renderViewer(port, "/errors"); + await screen.findByText("Connected to local receiver"); + await waitFor(() => { + expect(screen.getAllByLabelText("View event event")).toHaveLength(2); + }); + fireEvent.click(screen.getAllByLabelText("View event event")[1]!); + const sharedEventId = await waitFor(() => { + const eventId = new URLSearchParams(window.location.search).get( + "event", + ); + expect(eventId).not.toBeNull(); + return eventId!; + }); + + cleanup(); + renderViewer(port, `/errors?event=${encodeURIComponent(sharedEventId)}`); + await screen.findByText("Connected to local receiver"); + await waitFor(() => { + expect(screen.getAllByLabelText("View event event")).toHaveLength(2); + }); + expect( + screen + .getByRole("button", { name: "Open errors view" }) + .getAttribute("aria-current"), + ).toBe("page"); + expect(screen.getByTestId("event-detail").textContent).toContain( + "/shared-error", + ); + expect(screen.getByTestId("event-detail").textContent).not.toContain( + "/other-error", + ); + expect(new URLSearchParams(window.location.search).get("event")).toBe( + sharedEventId, + ); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("restores the prior selected event when browser history goes back", async () => { + const { server, port } = await startReceiver(); + + try { + renderViewer(port); + await screen.findByText("Connected to local receiver"); + await sendEnvelope(port, "GET /first"); + await sendEnvelope(port, "GET /second"); + + await waitFor(() => { + expect(screen.getAllByLabelText("View transaction event")).toHaveLength( + 2, + ); + }); + const events = screen.getAllByLabelText("View transaction event"); + fireEvent.click(events[1]!); + const selectedEventId = await waitFor(() => { + const eventId = new URLSearchParams(window.location.search).get( + "event", + ); + expect(eventId).not.toBeNull(); + return eventId!; + }); + expect(new URLSearchParams(window.location.search).get("event")).toBe( + selectedEventId, + ); + expect(screen.getByTestId("event-detail").textContent).toContain( + "/second", + ); + + await act(async () => { + window.history.back(); + }); + await waitFor(() => { + expect(screen.getByTestId("event-detail").textContent).toContain( + "/first", + ); + }); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("opens a global command result in its shareable Explorer view", async () => { + const { server, port } = await startReceiver(); + + try { + renderViewer(port); + await screen.findByText("Connected to local receiver"); + await sendEnvelope(port, "GET /healthy"); + await sendEnvelope(port, "GET /broken", { + type: "event", + level: "error", + }); + await screen.findByLabelText("View event event"); + + fireEvent.click( + screen.getAllByRole("button", { name: "Search events" })[0]!, + ); + fireEvent.change(screen.getByPlaceholderText("Search events and views"), { + target: { value: "broken" }, + }); + fireEvent.click(screen.getByRole("option", { name: /GET \/broken/i })); + + await waitFor(() => { + expect(window.location.pathname).toBe("/errors"); + expect( + new URLSearchParams(window.location.search).get("event"), + ).not.toBeNull(); + }); + expect(screen.getByTestId("event-detail").textContent).toContain( + "/broken", + ); + + await act(async () => { + window.history.back(); + }); + await waitFor(() => { + expect( + screen + .getByRole("button", { name: "Open live activity view" }) + .getAttribute("aria-current"), + ).toBe("page"); + }); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("clears the local viewer feed from the sidebar", async () => { + const { server, port } = await startReceiver(); + + try { + renderViewer(port); + await screen.findByText("Connected to local receiver"); + await sendEnvelope(port, "GET /clear-me"); + await screen.findByLabelText("View transaction event"); + + fireEvent.click(screen.getByRole("button", { name: "Clear events" })); + + expect(screen.queryByLabelText("View transaction event")).toBeNull(); + expect(screen.getByText("Waiting for events")).not.toBeNull(); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("summarizes related retained events in the trace waterfall", async () => { + const { server, port } = await startReceiver(); + + try { + renderViewer(port); + await screen.findByText("Connected to local receiver"); + await sendEnvelope(port, "GET /trace/first", { traceId: "shared-trace" }); + await sendEnvelope(port, "GET /trace/second", { + traceId: "shared-trace", + }); + + await waitFor(() => { + expect(screen.getAllByLabelText("View transaction event")).toHaveLength( + 2, + ); + }); + + const traceRelationship = + await screen.findByLabelText("Trace relationship"); + expect(traceRelationship.textContent).toContain("2 captured items"); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("offers new events without interrupting the selected detail", async () => { + const { server, port } = await startReceiver(); + + try { + renderViewer(port); + await screen.findByText("Connected to local receiver"); + await sendEnvelope(port, "GET /selected"); + fireEvent.click(await screen.findByLabelText("View transaction event")); + await sendEnvelope(port, "GET /new"); + + await waitFor(() => { + expect(screen.getAllByLabelText("View transaction event")).toHaveLength( + 2, + ); + }); + + expect(screen.getByTestId("event-detail").textContent).toContain( + "GET /selected", + ); + const newEvents = screen.getByRole("button", { + name: "View 1 new event", + }); + fireEvent.click(newEvents); + + expect(screen.getByTestId("event-detail").textContent).toContain( + "GET /new", + ); + expect( + screen.queryByRole("button", { name: "View 1 new event" }), + ).toBeNull(); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("keeps the event overview to non-repeated diagnostic details", async () => { + const { server, port } = await startReceiver(); + + try { + renderViewer(port); + await screen.findByText("Connected to local receiver"); + await sendEnvelope(port, "GET /summary", { + operation: "http.server", + startTimestamp: 1_699_999_999.99383, + statusCode: 201, + traceId: "summary-trace", + }); + + const details = await screen.findByRole("table", { + name: "Event details", + }); + expect(details.textContent).toContain("201"); + expect(details.textContent).toContain("6.17ms"); + expect(details.textContent).not.toContain("Method"); + expect(details.textContent).not.toContain("Route"); + expect(details.textContent).not.toContain("Trace ID"); + expect(details.textContent).not.toContain("Operation"); + expect(screen.getByTestId("event-detail").textContent).toContain( + "GET /summary", + ); + expect(screen.getByTestId("event-detail").textContent).toContain( + "http.server", + ); + + fireEvent.click(screen.getByRole("button", { name: "Copy Status" })); + expect( + screen.getByRole("status", { name: "Status copied" }), + ).not.toBeNull(); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("styles failing spans using their own telemetry", async () => { + const { server, port } = await startReceiver(); + + try { + renderViewer(port); + await screen.findByText("Connected to local receiver"); + await sendEnvelope(port, "GET /waterfall", { + level: "error", + statusCode: 500, + operation: "http.server", + spanId: "root-span", + traceId: "waterfall-trace", + startTimestamp: 1_699_999_999.988, + spans: [ + { + span_id: "db-span", + parent_span_id: "root-span", + op: "db.query", + description: "SELECT * FROM orders", + start_timestamp: 1_699_999_999.99, + timestamp: 1_699_999_999.994, + }, + { + span_id: "http-span", + parent_span_id: "root-span", + op: "http.client", + data: { "http.response.status_code": 503 }, + description: "GET https://inventory.test/items/42", + start_timestamp: 1_699_999_999.995, + timestamp: 1_699_999_999.998, + }, + ], + }); + + expect( + await screen.findByRole("region", { name: "Trace waterfall" }), + ).not.toBeNull(); + expect(screen.getByRole("columnheader", { name: "Span" })).not.toBeNull(); + expect( + screen.getByRole("columnheader", { name: "Timeline from 0ms to 12ms" }), + ).not.toBeNull(); + expect( + screen.getByRole("columnheader", { name: "Duration" }), + ).not.toBeNull(); + expect(screen.getByLabelText("Trace relationship").textContent).toContain( + "1 captured item", + ); + expect(screen.queryByLabelText("Trace summary")).toBeNull(); + expect(screen.getByText("db.query")).not.toBeNull(); + expect(screen.getByText("http.client")).not.toBeNull(); + expect( + screen.getByRole("cell", { name: "Duration 4.00ms" }), + ).not.toBeNull(); + expect(screen.getByTestId("waterfall-bar-root-span").className).toContain( + "bg-red-500", + ); + expect( + screen.getByTestId("waterfall-bar-db-span").className, + ).not.toContain("bg-red-500"); + expect(screen.getByTestId("waterfall-bar-http-span").className).toContain( + "bg-red-500", + ); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("groups trace records into one trace investigation", async () => { + const { server, port } = await startReceiver(); + + try { + renderViewer(port); + await screen.findByText("Connected to local receiver"); + await sendEnvelope(port, "GET /trace-parent", { + operation: "http.server", + spanId: "parent-span", + traceId: "grouped-trace", + }); + await sendEnvelope(port, "GET /trace-child", { + operation: "db.query", + spanId: "child-span", + traceId: "grouped-trace", + }); + + fireEvent.click(screen.getByRole("button", { name: "Open traces view" })); + + expect( + (await screen.findAllByRole("heading", { name: "Traces" })).length, + ).toBeGreaterThan(0); + expect(screen.getByText("1 trace")).not.toBeNull(); + const traceList = screen.getByLabelText("Trace list"); + expect(traceList.className).toContain("w-80"); + expect(traceList.className).toContain("bg-muted/30"); + + const traceRow = screen.getByRole("button", { + name: "View trace GET /trace-parent", + }); + expect(traceRow.className).toContain("px-2"); + expect(traceRow.className).toContain("py-2"); + expect(traceRow.className).not.toContain("rounded-md"); + + const waterfall = screen.getByRole("region", { name: "Trace waterfall" }); + expect(waterfall.parentElement?.className).not.toContain("p-4"); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("uses the active Explorer view label and gives empty views a useful explanation", async () => { + const { server, port } = await startReceiver(); + + try { + renderViewer(port); + await screen.findByText("Connected to local receiver"); + await sendEnvelope(port, "GET /broken", { + type: "event", + level: "error", + }); + + fireEvent.click(screen.getByRole("button", { name: "Open errors view" })); + expect( + await screen.findByRole("heading", { name: "Errors" }), + ).not.toBeNull(); + expect(screen.getByText("1 error")).not.toBeNull(); + + fireEvent.click( + screen.getByRole("button", { name: "Open feedback view" }), + ); + expect( + await screen.findByRole("heading", { name: "No feedback received" }), + ).not.toBeNull(); + expect( + screen.getByText( + "User feedback submitted through supported SDKs will appear here.", + ), + ).not.toBeNull(); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("moves between detail tabs with arrow keys", async () => { + const { server, port } = await startReceiver(); + + try { + renderViewer(port); + await screen.findByText("Connected to local receiver"); + await sendEnvelope(port, "GET /tabs"); + + const overview = await screen.findByRole("tab", { name: "Overview" }); + fireEvent.keyDown(overview, { key: "ArrowRight" }); + + expect( + screen.getByRole("tab", { name: "JSON" }).getAttribute("aria-selected"), + ).toBe("true"); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("reserves space for the CLI logo", async () => { + const { server, port } = await startReceiver(); + + try { + renderViewer(port); + + const logo = await screen.findByAltText("Sentry CLI"); + expect(logo.getAttribute("width")).toBe("117"); + expect(logo.getAttribute("height")).toBe("20"); + } finally { + cleanup(); + await stopReceiver(server); + } + }); +}); diff --git a/apps/local/src/App.tsx b/apps/local/src/App.tsx new file mode 100644 index 000000000..766b2109d --- /dev/null +++ b/apps/local/src/App.tsx @@ -0,0 +1,1387 @@ +import { RetentionSettings } from "./components/retention-settings.tsx"; +import { + DEFAULT_MAX_ENVELOPES, + isValidMaxEnvelopes, + LOCAL_RETENTION_EVENT, +} from "../../../packages/cli/src/commands/local/retention-limits.ts"; +import { + Check, + ChevronLeft, + ChevronRight, + Copy, + Menu, + Radio, + Search, + Terminal, + Trash2, + X, +} from "lucide-react"; +import { + Fragment, + type KeyboardEvent, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import { + useCopyToClipboard, + useDebounce, + useLocalStorage, +} from "@uidotdev/usehooks"; +import { JsonView } from "@/components/json-view.tsx"; +import { ConnectionLanding } from "@/components/connection-landing.tsx"; +import { + EventCommandDialog, + type CommandNavigationItem, +} from "@/components/event-command-dialog.tsx"; +import { ReceiverControls } from "@/components/receiver-controls.tsx"; +import { TraceWorkspace } from "@/components/trace-workspace.tsx"; +import { TraceWaterfall } from "@/components/trace-waterfall.tsx"; +import { Badge } from "@/components/ui/badge.tsx"; +import { + getConnectionPresentation, + type ConnectionPresentation, + type ConnectionState, +} from "@/lib/presentation.ts"; +import { + DEFAULT_STREAM_URL, + decodeEnvelope, + parseStreamEndpoint, + isLoopbackHost, + REMOTE_STREAM_STORAGE_KEY, + resolveInitialStreamUrl, + SENTRY_ENVELOPE_EVENT, + STREAM_STORAGE_KEY, + type EventMetadata, + type LocalFeedItem, +} from "@/lib/spotlight.ts"; +import { + createLocalTelemetryStore, + type LocalTelemetrySnapshot, +} from "@/lib/telemetry-store.ts"; +import { + buildTraceGroups, + traceIdForItem, + type TraceGroup, +} from "@/lib/trace-model.ts"; +import { workspaceNavigation } from "@/lib/workspace-navigation.ts"; +import { + isErrorEvent, + matchesSearch, + type WorkspaceView, + workspaceForItem, +} from "@/lib/workspace.ts"; +import { useLocalWorkspaceRoute } from "@/routes/local-workspace-context.ts"; + +type EventEntryProps = { + item: LocalFeedItem; + isSelected: boolean; + showTypeBadge: boolean; + onSelect: (id: string) => void; +}; + +const CONNECTION_TIMEOUT_MS = 10_000; +const SEARCH_DEBOUNCE_MS = 150; +const SIDEBAR_COLLAPSED_STORAGE_KEY = "sentry.local.sidebar-collapsed"; +const SCOPED_SESSION_STREAM_PATH_RE = /^\/_local\/v1\/sessions\/[^/]+\/stream$/; + +function isScopedSessionStream(pathname: string): boolean { + return SCOPED_SESSION_STREAM_PATH_RE.test(pathname); +} + +function formatTimestamp(timestamp: LocalFeedItem["timestamp"]): string { + if (timestamp === undefined) { + return "unknown time"; + } + const milliseconds = + typeof timestamp === "number" && timestamp < 1_000_000_000_000 + ? timestamp * 1_000 + : timestamp; + const date = new Date(milliseconds); + return Number.isNaN(date.getTime()) + ? String(timestamp) + : date.toLocaleTimeString(); +} + +function formatDuration(durationMs: number | undefined): string | undefined { + if (durationMs === undefined) { + return undefined; + } + return `${durationMs.toFixed(durationMs < 10 ? 2 : 0)}ms`; +} + +function getMetadata(item: LocalFeedItem): EventMetadata { + return item.metadata ?? { title: item.type }; +} + +function getStatusClass(statusCode: number | undefined): string { + if (statusCode === undefined) { + return "text-muted-foreground"; + } + if (statusCode >= 500) { + return "text-red-500 dark:text-red-400"; + } + if (statusCode >= 400) { + return "text-amber-600 dark:text-amber-400"; + } + return "text-emerald-600 dark:text-emerald-400"; +} + +function getSavedStream(): string | null { + try { + return window.localStorage.getItem(STREAM_STORAGE_KEY); + } catch { + return null; + } +} + +function getSavedRemoteStream(): string | null { + try { + return window.sessionStorage.getItem(REMOTE_STREAM_STORAGE_KEY); + } catch { + return null; + } +} + +function saveStreamUrl(streamUrl: string): void { + try { + window.localStorage.setItem(STREAM_STORAGE_KEY, streamUrl); + } catch { + // Private browsing or browser policy can disable storage; the current tab still works. + } +} + +function saveRemoteStreamUrl(streamUrl: string): void { + try { + window.sessionStorage.setItem(REMOTE_STREAM_STORAGE_KEY, streamUrl); + } catch { + // Browser policies can disable storage; the active tab still works. + } +} + +function EventEntry({ + item, + isSelected, + showTypeBadge, + onSelect, +}: EventEntryProps) { + const metadata = getMetadata(item); + const duration = formatDuration(metadata.durationMs); + const isError = isErrorEvent(item); + + return ( +
  • + +
  • + ); +} + +type EventDetailProps = { + item: LocalFeedItem; + trace?: TraceGroup; +}; + +type DetailTab = "overview" | "json"; + +type DetailField = { + label: string; + value: string; +}; + +type DetailGroup = { + label: string; + fields: DetailField[]; +}; + +function RawEnvelopeDetail({ item }: { item: LocalFeedItem }) { + return ( +
    +
    +
    +

    Raw envelope

    +

    + Received {formatTimestamp(item.timestamp)} +

    +
    + Envelope +
    +
    + +
    +
    + ); +} + +function WorkspaceEmptyState({ + title, + description, +}: { + title: string; + description: string; +}) { + return ( +
    +
    +

    {title}

    +

    + {description} +

    +
    +
    + ); +} + +function EventDetail({ item, trace }: EventDetailProps) { + const [tab, setTab] = useState("overview"); + const [copiedField, setCopiedField] = useState(); + const [, copyToClipboard] = useCopyToClipboard(); + const detailTabs: DetailTab[] = ["overview", "json"]; + const metadata = getMetadata(item); + const duration = formatDuration(metadata.durationMs); + const detailGroups: DetailGroup[] = [ + { + label: "Details", + fields: [ + ...(metadata.statusCode !== undefined + ? [{ label: "Status", value: String(metadata.statusCode) }] + : []), + ...(duration ? [{ label: "Duration", value: duration }] : []), + ], + }, + { + label: "Telemetry", + fields: [ + ...(metadata.origin + ? [{ label: "Origin", value: metadata.origin }] + : []), + ], + }, + ]; + const hasDetailFields = detailGroups.some((group) => group.fields.length > 0); + + const handleTabKeyDown = (event: KeyboardEvent) => { + const currentIndex = detailTabs.indexOf(tab); + const nextIndex = + event.key === "ArrowRight" + ? (currentIndex + 1) % detailTabs.length + : event.key === "ArrowLeft" + ? (currentIndex - 1 + detailTabs.length) % detailTabs.length + : event.key === "Home" + ? 0 + : event.key === "End" + ? detailTabs.length - 1 + : undefined; + + if (nextIndex === undefined) { + return; + } + + event.preventDefault(); + const nextTab = detailTabs[nextIndex]!; + setTab(nextTab); + requestAnimationFrame(() => { + document.getElementById(`event-detail-tab-${nextTab}`)?.focus(); + }); + }; + + return ( +
    +
    +
    + {metadata.operation ?? item.type} + {metadata.title} +
    +
    +
    + + +
    + +
    +
    + {tab === "overview" ? ( +
    +
    + {hasDetailFields ? ( + + + + {detailGroups.map((group) => + group.fields.length > 0 ? ( + + + + + {group.fields.map((field) => { + const copied = copiedField === field.label; + return ( + + + + + ); + })} + + ) : null, + )} + +
    Captured event details
    + {group.label} +
    + {field.label} + +
    + + {field.value} + + +
    +
    + ) : ( +

    + No additional event details. +

    + )} + {copiedField ? ( + + {copiedField} copied + + ) : null} + {trace ? ( +
    + +
    + ) : null} +
    +
    + ) : ( +
    + +
    + )} +
    + ); +} + +type WorkspaceSidebarProps = { + activeView: WorkspaceView; + canSearch: boolean; + collapsed: boolean; + connection: ConnectionPresentation; + retainedItemCount: number; + snapshot: LocalTelemetrySnapshot; + traceCount: number; + onClear: () => void; + onCloseSession?: () => void; + onChangeReceiver: () => void; + onOpenCommand: (trigger: HTMLButtonElement) => void; + onSelect: (view: WorkspaceView) => void; + onToggle: () => void; +}; + +function WorkspaceSidebar({ + activeView, + canSearch, + collapsed, + connection, + retainedItemCount, + snapshot, + traceCount, + onClear, + onCloseSession, + onChangeReceiver, + onOpenCommand, + onSelect, + onToggle, +}: WorkspaceSidebarProps) { + const statusLabel = connection.label.replace(" to local receiver", ""); + const statusDotClass = + connection.tone === "success" + ? "bg-emerald-500 shadow-[0_0_10px_oklch(0.72_0.19_160)]" + : connection.tone === "warning" + ? "bg-amber-500" + : "bg-muted-foreground"; + + return ( + + ); +} + +export default function App() { + const { + navigateToCommandEvent, + navigateToCommandTrace, + navigateToWorkspace, + workspaceQuery, + workspaceView, + } = useLocalWorkspaceRoute(); + const [commandSearchQuery, setCommandSearchQuery] = useState( + workspaceQuery.query, + ); + const debouncedCommandSearchQuery = useDebounce( + commandSearchQuery, + SEARCH_DEBOUNCE_MS, + ); + const isClearingCommandSearch = useRef(false); + const [streamUrl, setStreamUrl] = useState(() => + resolveInitialStreamUrl( + window.location.hash, + getSavedStream(), + getSavedRemoteStream(), + ), + ); + const activeStreamUrl = useRef(streamUrl); + const [connectionAttempt, setConnectionAttempt] = useState(0); + const [connection, setConnection] = useState("connecting"); + const [draftEndpoint, setDraftEndpoint] = useState(streamUrl); + const [isEditingReceiver, setIsEditingReceiver] = useState(false); + const [isConnectionEnabled, setIsConnectionEnabled] = useState(true); + const [isConnecting, setIsConnecting] = useState(false); + const [connectionError, setConnectionError] = useState(); + const [retentionLimit, setRetentionLimit] = useState(DEFAULT_MAX_ENVELOPES); + const [hasRetentionSettings, setHasRetentionSettings] = useState(false); + const [telemetryStore] = useState(() => createLocalTelemetryStore()); + const telemetry = useSyncExternalStore( + telemetryStore.subscribe, + telemetryStore.getSnapshot, + telemetryStore.getSnapshot, + ); + const items = telemetry.items; + const [lastViewedItemIds, setLastViewedItemIds] = useState< + Partial> + >({}); + const [isSidebarCollapsed, setIsSidebarCollapsed] = useLocalStorage( + SIDEBAR_COLLAPSED_STORAGE_KEY, + false, + ); + const [isMobileNavigationOpen, setIsMobileNavigationOpen] = useState(false); + const [isCommandOpen, setIsCommandOpen] = useState(false); + const [message, setMessage] = useState(); + const fallbackEventId = useRef(0); + const commandTriggerRef = useRef(null); + const presentation = getConnectionPresentation(connection); + const traces = useMemo(() => buildTraceGroups(items), [items]); + const searchQuery = workspaceQuery.query; + const activeWorkspace = workspaceNavigation.find( + (entry) => entry.id === workspaceView, + )!; + const workspaceItems = activeWorkspace.getItems(telemetry); + const hasRetainedItems = items.length > 0 || telemetry.envelopes.length > 0; + const retainedItemCount = items.length + telemetry.envelopes.length; + const searchedItems = workspaceItems.filter((item) => + matchesSearch(item, searchQuery), + ); + const searchedTraces = traces.filter((trace) => + trace.items.some((item) => matchesSearch(item, searchQuery)), + ); + const workspaceEmptyState = searchQuery.trim() + ? { + title: `No matching ${activeWorkspace.singularLabel}s`, + description: + "Try a different search term or clear the search to see everything in this view.", + } + : activeWorkspace.emptyState; + const selectedItem = + searchedItems.find((item) => item.id === workspaceQuery.eventId) ?? + searchedItems[0]; + const selectedEventTrace = selectedItem?.metadata?.traceId + ? traces.find((trace) => trace.id === selectedItem.metadata?.traceId) + : undefined; + const lastViewedItemId = lastViewedItemIds[workspaceView]; + const lastViewedIndex = lastViewedItemId + ? workspaceItems.findIndex((item) => item.id === lastViewedItemId) + : -1; + const unseenItems = + lastViewedItemId === undefined + ? [] + : lastViewedIndex === -1 + ? workspaceItems + : workspaceItems.slice(lastViewedIndex + 1); + const newItems = unseenItems.filter((item) => + matchesSearch(item, searchQuery), + ); + const newItemCount = newItems.length; + + useEffect(() => { + if (isClearingCommandSearch.current) { + if (debouncedCommandSearchQuery !== commandSearchQuery) { + return; + } + isClearingCommandSearch.current = false; + } + if (debouncedCommandSearchQuery !== searchQuery) { + void workspaceQuery.setSearchQuery(debouncedCommandSearchQuery); + } + }, [ + commandSearchQuery, + debouncedCommandSearchQuery, + searchQuery, + workspaceQuery, + ]); + + const markItemsSeen = (view = workspaceView) => { + const viewedItems = workspaceNavigation + .find((entry) => entry.id === view)! + .getItems(telemetry); + setLastViewedItemIds((itemIds) => ({ + ...itemIds, + [view]: viewedItems.at(-1)?.id, + })); + }; + + const selectItem = (id: string) => { + void workspaceQuery.selectEvent(id); + markItemsSeen(); + }; + + const selectWorkspace = (nextWorkspace: WorkspaceView) => { + navigateToWorkspace(nextWorkspace); + void workspaceQuery.resetWorkspace(); + setIsMobileNavigationOpen(false); + markItemsSeen(nextWorkspace); + }; + + const selectCommandItem = (item: LocalFeedItem) => { + isClearingCommandSearch.current = true; + setCommandSearchQuery(""); + const nextWorkspace = workspaceForItem(item); + const traceId = traceIdForItem(item); + if (nextWorkspace === "traces" && traceId) { + navigateToCommandTrace(nextWorkspace, traceId); + } else { + navigateToCommandEvent(nextWorkspace, item.id); + } + markItemsSeen(nextWorkspace); + }; + + const clearItems = () => { + isClearingCommandSearch.current = true; + setCommandSearchQuery(""); + telemetryStore.clear(); + setMessage(undefined); + setLastViewedItemIds({}); + void workspaceQuery.clearWorkspace(); + + const endpoint = parseStreamEndpoint(streamUrl); + if ( + endpoint?.kind !== "loopback" || + !isLoopbackHost(window.location.hostname) + ) { + return; + } + const clearUrl = new URL(endpoint.url); + const scopedSession = isScopedSessionStream(clearUrl.pathname); + if (scopedSession) { + clearUrl.pathname = clearUrl.pathname.replace(/\/stream$/, "/ui/reset"); + } else { + clearUrl.pathname = "/clear"; + clearUrl.search = ""; + } + void fetch(clearUrl, { method: scopedSession ? "POST" : "DELETE" }) + .then((response) => { + if (!response.ok) + throw new Error(`Session reset failed with HTTP ${response.status}`); + }) + .catch(() => { + setMessage( + "Cleared this viewer, but the receiver could not clear its retained session.", + ); + }); + }; + + const closeScopedSession = () => { + const endpoint = parseStreamEndpoint(streamUrl); + if ( + endpoint?.kind !== "loopback" || + !isLoopbackHost(window.location.hostname) + ) { + return; + } + const closeUrl = new URL(endpoint.url); + if (!isScopedSessionStream(closeUrl.pathname)) { + return; + } + closeUrl.pathname = closeUrl.pathname.replace(/\/stream$/, "/ui/close"); + void fetch(closeUrl, { method: "POST" }) + .then((response) => { + if (!response.ok) { + throw new Error(`Session close failed with HTTP ${response.status}`); + } + setIsConnectionEnabled(false); + setIsConnecting(false); + setConnection("closed"); + setMessage( + "Telemetry session closed. Retained events remain available for three hours.", + ); + }) + .catch(() => setMessage("Could not close this telemetry session.")); + }; + + const currentEndpoint = parseStreamEndpoint(streamUrl); + const canEditRetention = + currentEndpoint?.kind === "loopback" && + isLoopbackHost(window.location.hostname); + const hasScopedSession = + currentEndpoint?.kind === "loopback" && + isLoopbackHost(window.location.hostname) && + isScopedSessionStream(new URL(currentEndpoint.url).pathname); + + const connectToDraft = () => { + const endpoint = parseStreamEndpoint(draftEndpoint); + if (!endpoint) { + setConnectionError( + "Enter a loopback stream or an HTTPS remote stream ending in /stream.", + ); + if (connection !== "connected") { + setIsConnectionEnabled(false); + setConnection("failed"); + } + return; + } + if (endpoint.url !== streamUrl) { + activeStreamUrl.current = endpoint.url; + telemetryStore.clear(); + telemetryStore.setLimit(DEFAULT_MAX_ENVELOPES); + setRetentionLimit(DEFAULT_MAX_ENVELOPES); + setHasRetentionSettings(false); + setLastViewedItemIds({}); + fallbackEventId.current = 0; + isClearingCommandSearch.current = true; + setCommandSearchQuery(""); + void workspaceQuery.clearWorkspace(); + } + setDraftEndpoint(endpoint.url); + setStreamUrl(endpoint.url); + setConnectionAttempt((attempt) => attempt + 1); + setIsConnectionEnabled(true); + setIsConnecting(true); + setConnection("connecting"); + setConnectionError(undefined); + setIsEditingReceiver(false); + }; + + useEffect(() => { + if (window.location.hash) { + window.history.replaceState( + null, + "", + `${window.location.pathname}${window.location.search}`, + ); + } + }, []); + + useEffect(() => { + if (!isConnectionEnabled) { + return; + } + + let isCurrent = true; + const requestUrl = new URL(streamUrl); + if (parseStreamEndpoint(streamUrl)?.kind === "loopback") + requestUrl.searchParams.set("retention", "1"); + const source = new EventSource(requestUrl.toString()); + const onRetention = (event: Event) => { + if (!isCurrent || activeStreamUrl.current !== streamUrl) return; + try { + const settings: unknown = JSON.parse( + (event as MessageEvent).data, + ); + if ( + typeof settings === "object" && + settings !== null && + "maxEnvelopes" in settings && + isValidMaxEnvelopes(settings.maxEnvelopes) + ) { + telemetryStore.setLimit(settings.maxEnvelopes); + setRetentionLimit(settings.maxEnvelopes); + setHasRetentionSettings(true); + } + } catch { + /* Ignore malformed optional metadata from external receivers. */ + } + }; + source.addEventListener(LOCAL_RETENTION_EVENT, onRetention); + const timeoutId = window.setTimeout(() => { + if (!isCurrent || activeStreamUrl.current !== streamUrl) { + return; + } + isCurrent = false; + source.close(); + setIsConnectionEnabled(false); + setIsConnecting(false); + setConnection("failed"); + setConnectionError( + "Connection timed out after 10 seconds. Check the endpoint and try again.", + ); + }, CONNECTION_TIMEOUT_MS); + const onEnvelope = (event: Event) => { + if (!isCurrent || activeStreamUrl.current !== streamUrl) { + return; + } + const messageEvent = event as MessageEvent; + const eventId = + messageEvent.lastEventId || `event-${fallbackEventId.current++}`; + const rawEnvelope: LocalFeedItem = { + id: eventId, + type: "envelope", + timestamp: Date.now(), + text: messageEvent.data, + payload: messageEvent.data, + metadata: { title: `Envelope ${eventId.slice(-8)}` }, + }; + try { + telemetryStore.recordEnvelope( + rawEnvelope, + decodeEnvelope(messageEvent.data, eventId), + ); + setMessage(undefined); + } catch { + telemetryStore.recordEnvelope(rawEnvelope, []); + setMessage("Received an event that could not be decoded."); + } + }; + + source.addEventListener(SENTRY_ENVELOPE_EVENT, onEnvelope); + source.onopen = () => { + if (!isCurrent || activeStreamUrl.current !== streamUrl) { + return; + } + window.clearTimeout(timeoutId); + setIsConnecting(false); + const endpoint = parseStreamEndpoint(streamUrl); + if (endpoint?.kind === "loopback") { + saveStreamUrl(endpoint.url); + } else if (endpoint?.kind === "remote") { + saveRemoteStreamUrl(endpoint.url); + } + setConnection("connected"); + setConnectionError(undefined); + setMessage(undefined); + }; + source.onerror = () => { + if (!isCurrent || activeStreamUrl.current !== streamUrl) { + return; + } + window.clearTimeout(timeoutId); + isCurrent = false; + source.close(); + setIsConnectionEnabled(false); + setIsConnecting(false); + setConnection("failed"); + setConnectionError( + streamUrl === DEFAULT_STREAM_URL + ? "Could not connect to the default receiver. Start it with the command below or enter another endpoint." + : "Could not connect to this receiver. Check the endpoint and try again.", + ); + }; + + return () => { + isCurrent = false; + window.clearTimeout(timeoutId); + source.removeEventListener(SENTRY_ENVELOPE_EVENT, onEnvelope); + source.removeEventListener(LOCAL_RETENTION_EVENT, onRetention); + source.close(); + }; + }, [connectionAttempt, isConnectionEnabled, streamUrl, telemetryStore]); + + const showConnectionLanding = + isEditingReceiver || + (!hasRetainedItems && + (connection === "connecting" || + connection === "failed" || + connection === "closed")); + const isReceiverUnavailable = + connection !== "connected" && !isConnecting && !hasRetainedItems; + const canSearch = hasRetainedItems && !isEditingReceiver; + const commandNavigation: CommandNavigationItem[] = workspaceNavigation.map( + ({ icon, id, label }) => ({ icon, id, label }), + ); + const setCommandOpen = (open: boolean) => { + if (open) { + setCommandSearchQuery(searchQuery); + } + setIsCommandOpen(open); + }; + const openCommand = (trigger: HTMLButtonElement) => { + commandTriggerRef.current = trigger; + setCommandOpen(true); + }; + + return ( +
    +
    + {!isReceiverUnavailable ? ( + setIsEditingReceiver(true)} + onOpenCommand={openCommand} + onSelect={selectWorkspace} + onToggle={() => setIsSidebarCollapsed((collapsed) => !collapsed)} + /> + ) : null} +
    + {isReceiverUnavailable ? ( +
    +
    + Sentry CLI + +
    +
    + ) : ( +
    + + {canSearch ? ( + + ) : ( + + )} + +
    + )} + + {!isReceiverUnavailable && isMobileNavigationOpen ? ( +
    + +
    + ) : null} + +
    +
    + {hasRetentionSettings ? ( + + ) : null} + {connectionError && + connection === "failed" && + hasRetainedItems ? ( +
    + {connectionError} +
    + ) : null} + {message ? ( +
    + {message} +
    + ) : null} + + {showConnectionLanding ? ( + + ) : !hasRetainedItems ? ( +
    +
    + ) : workspaceView === "traces" ? ( + { + void workspaceQuery.selectTrace(traceId); + }} + /> + ) : ( +
    + + {searchedItems.length === 0 ? ( + + ) : selectedItem?.type === "envelope" ? ( + + ) : selectedItem ? ( + + ) : null} +
    + )} +
    +
    +
    +
    + +
    + ); +} diff --git a/apps/local/src/components/connection-landing.tsx b/apps/local/src/components/connection-landing.tsx new file mode 100644 index 000000000..badf85391 --- /dev/null +++ b/apps/local/src/components/connection-landing.tsx @@ -0,0 +1,139 @@ +import { Check, Copy, LoaderCircle, Radio } from "lucide-react"; +import { type FormEvent, useState } from "react"; +import { useCopyToClipboard } from "@uidotdev/usehooks"; + +export type ConnectionLandingProps = { + endpoint: string; + error?: string; + isConnecting: boolean; + phase: "probing" | "failed" | "editing"; + onConnect: () => void; + onEndpointChange: (value: string) => void; +}; + +/** A compact, explicit receiver picker for bare Local viewer visits. */ +export function ConnectionLanding({ + endpoint, + error, + isConnecting, + phase, + onConnect, + onEndpointChange, +}: ConnectionLandingProps) { + const [copied, setCopied] = useState(false); + const [, copyToClipboard] = useCopyToClipboard(); + const isProbing = phase === "probing"; + + const submit = (event: FormEvent) => { + event.preventDefault(); + if (isConnecting) { + return; + } + onConnect(); + }; + + return ( +
    +
    +
    +
    + {isProbing ? ( + + ) : null} +
    +
    +

    + Sentry Local +

    +

    + {isProbing ? "Looking for Sentry Local" : "Connect a receiver"} +

    +

    + {isProbing + ? "Checking the selected receiver." + : "Connect this read-only viewer to an event stream."} +

    +
    +
    + + {error ? ( +

    + {error} +

    + ) : null} + +
    + +
    + onEndpointChange(event.target.value)} + className="h-9 min-w-0 flex-1 border border-border bg-background px-3 font-mono text-sm outline-none focus:border-primary focus:ring-2 focus:ring-primary/20" + /> + +
    +
    + +

    + Defaults to your local receiver. You can paste another local or HTTPS + stream above. +

    + +
    + + sentry local serve --open + + +
    +
    +
    + ); +} diff --git a/apps/local/src/components/event-command-dialog.test.tsx b/apps/local/src/components/event-command-dialog.test.tsx new file mode 100644 index 000000000..23d592f87 --- /dev/null +++ b/apps/local/src/components/event-command-dialog.test.tsx @@ -0,0 +1,149 @@ +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { ListTree } from "lucide-react"; +import { useRef, useState } from "react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { + EventCommandDialog, + type CommandNavigationItem, +} from "./event-command-dialog.tsx"; + +const items = [ + { + id: "transaction-1", + type: "transaction", + text: "{}", + metadata: { + title: "GET /api/users/42", + method: "GET", + route: "/api/users/42", + }, + }, +]; + +const navigation: CommandNavigationItem[] = [ + { id: "traces", label: "Traces", icon: ListTree }, +]; + +afterEach(cleanup); + +describe("EventCommandDialog", () => { + test("opens with Meta+K and selects a fuzzy-matched retained event", () => { + const onSelectItem = vi.fn(); + + function Harness() { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + return ( + + ); + } + + render(); + fireEvent.keyDown(window, { key: "k", metaKey: true }); + fireEvent.change(screen.getByPlaceholderText("Search events and views"), { + target: { value: "usr 42" }, + }); + fireEvent.click( + screen.getByRole("option", { name: /GET \/api\/users\/42/i }), + ); + + expect(onSelectItem).toHaveBeenCalledWith( + expect.objectContaining({ id: "transaction-1" }), + ); + }); + + test("offers Explorer navigation alongside events", () => { + render( + , + ); + + expect( + screen.getByRole("dialog", { name: "Search Local events" }), + ).not.toBeNull(); + expect(screen.getByRole("option", { name: "Traces" })).not.toBeNull(); + }); + + test("closes on Escape and returns focus to the search trigger", async () => { + function Harness() { + const [open, setOpen] = useState(true); + const triggerRef = useRef(null); + return ( + <> + + + + ); + } + + render(); + fireEvent.keyDown(screen.getByPlaceholderText("Search events and views"), { + key: "Escape", + }); + + await waitFor(() => { + expect(document.activeElement).toBe( + screen.getByRole("button", { name: "Search events" }), + ); + }); + }); + + test("offers the current-view filter when a search query is present", () => { + render( + , + ); + + expect(screen.getByText("Filter current view")).not.toBeNull(); + expect( + screen.getByRole("option", { name: /Filter current view for “users”/ }), + ).not.toBeNull(); + }); +}); diff --git a/apps/local/src/components/event-command-dialog.tsx b/apps/local/src/components/event-command-dialog.tsx new file mode 100644 index 000000000..d2b70cc57 --- /dev/null +++ b/apps/local/src/components/event-command-dialog.tsx @@ -0,0 +1,157 @@ +import type { LucideIcon } from "lucide-react"; +import { Search } from "lucide-react"; +import { type RefObject, useEffect } from "react"; +import { + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command.tsx"; +import type { LocalFeedItem } from "@/lib/spotlight.ts"; +import { eventSearchValue, type WorkspaceView } from "@/lib/workspace.ts"; + +export type CommandNavigationItem = { + icon: LucideIcon; + id: WorkspaceView; + label: string; +}; + +type EventCommandDialogProps = { + enabled: boolean; + items: LocalFeedItem[]; + navigation: CommandNavigationItem[]; + open: boolean; + query: string; + triggerRef?: RefObject; + onNavigate: (view: WorkspaceView) => void; + onOpenChange: (open: boolean) => void; + onQueryChange: (query: string) => void; + onSelectItem: (item: LocalFeedItem) => void; +}; + +export function EventCommandDialog({ + enabled, + items, + navigation, + open, + query, + triggerRef, + onNavigate, + onOpenChange, + onQueryChange, + onSelectItem, +}: EventCommandDialogProps) { + useEffect(() => { + if (!enabled) { + return; + } + + const openWithShortcut = (event: KeyboardEvent) => { + if ( + (!event.metaKey && !event.ctrlKey) || + event.key.toLowerCase() !== "k" + ) { + return; + } + + const target = event.target; + if ( + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target instanceof HTMLSelectElement || + (target instanceof HTMLElement && target.isContentEditable) + ) { + return; + } + + event.preventDefault(); + onOpenChange(true); + }; + + window.addEventListener("keydown", openWithShortcut); + return () => window.removeEventListener("keydown", openWithShortcut); + }, [enabled, onOpenChange]); + + const close = () => { + onOpenChange(false); + queueMicrotask(() => triggerRef?.current?.focus()); + }; + + return ( + { + if (nextOpen) { + onOpenChange(true); + return; + } + close(); + }} + > + + + No matching events or views. + + {navigation.map((entry) => { + const Icon = entry.icon; + return ( + { + onNavigate(entry.id); + close(); + }} + > + + ); + })} + + + {items.map((item) => ( + { + onSelectItem(item); + close(); + }} + > + + ))} + + {query.trim() ? ( + + + + + ) : null} + + + ); +} diff --git a/apps/local/src/components/json-view-updates.test.tsx b/apps/local/src/components/json-view-updates.test.tsx new file mode 100644 index 000000000..9961ddee6 --- /dev/null +++ b/apps/local/src/components/json-view-updates.test.tsx @@ -0,0 +1,59 @@ +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +const { highlight } = vi.hoisted(() => ({ + highlight: vi.fn<(code: string) => string | Promise>(), +})); +vi.mock("@shikijs/core", () => ({ + createHighlighterCore: () => Promise.resolve({ codeToHtml: highlight }), +})); +import { JsonView } from "./json-view.tsx"; + +beforeEach(() => highlight.mockReset()); +afterEach(() => cleanup()); +describe("JsonView updates", () => { + test.each(["pending", "failed"])( + "shows current source when replacement highlighting is %s", + async (mode) => { + highlight.mockReturnValueOnce("
    first envelope
    "); + let finish: ((html: string) => void) | undefined; + if (mode === "pending") { + highlight.mockReturnValueOnce( + new Promise((resolve) => { + finish = resolve; + }), + ); + } else { + highlight.mockImplementationOnce(() => { + throw new Error("highlight failed"); + }); + } + const view = render(); + await waitFor(() => + expect(screen.getByTestId("highlighted-json").textContent).toBe( + "first envelope", + ), + ); + view.rerender(); + expect(screen.getByTestId("highlighted-json").textContent).toContain( + '"second"', + ); + expect(screen.getByTestId("highlighted-json").textContent).not.toContain( + "first envelope", + ); + if (finish) { + finish("
    second envelope
    "); + await waitFor(() => + expect(screen.getByTestId("highlighted-json").textContent).toBe( + "second envelope", + ), + ); + } else { + await waitFor(() => expect(highlight).toHaveBeenCalledTimes(2)); + expect(screen.getByTestId("highlighted-json").textContent).toContain( + '"second"', + ); + } + }, + ); +}); diff --git a/apps/local/src/components/json-view.test.tsx b/apps/local/src/components/json-view.test.tsx new file mode 100644 index 000000000..34e8a5804 --- /dev/null +++ b/apps/local/src/components/json-view.test.tsx @@ -0,0 +1,23 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; +import { JsonView } from "./json-view.tsx"; + +describe("JsonView", () => { + test("formats valid JSON for a wrapped inspector", async () => { + render( + , + ); + + const inspector = screen.getByTestId("highlighted-json"); + + await waitFor(() => { + expect(inspector.textContent).toBe( + '{\n "event_id": "event-1",\n "context": {\n "route": "/api/users/42"\n }\n}', + ); + }); + expect(inspector.className).toContain("whitespace-pre-wrap"); + expect(inspector.className).toContain("break-words"); + }); +}); diff --git a/apps/local/src/components/json-view.tsx b/apps/local/src/components/json-view.tsx new file mode 100644 index 000000000..64f4ed072 --- /dev/null +++ b/apps/local/src/components/json-view.tsx @@ -0,0 +1,122 @@ +import { createHighlighterCore } from "@shikijs/core"; +import { createJavaScriptRegexEngine } from "@shikijs/engine-javascript"; +import json from "@shikijs/langs/json"; +import githubDark from "@shikijs/themes/github-dark"; +import githubLight from "@shikijs/themes/github-light"; +import { Check, Copy } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useCopyToClipboard } from "@uidotdev/usehooks"; + +type JsonViewProps = { + code: string; +}; + +const highlighter = createHighlighterCore({ + engine: createJavaScriptRegexEngine(), + langs: [json], + themes: [githubLight, githubDark], +}); + +function formatJson(code: string) { + try { + return JSON.stringify(JSON.parse(code), null, 2); + } catch { + return code; + } +} + +/** Render JSON with Shiki only after its containing event has been expanded. */ +export function JsonView({ code }: JsonViewProps) { + const [highlighted, setHighlighted] = useState<{ + source: string; + html: string; + }>(); + const [copiedText, copyToClipboard] = useCopyToClipboard(); + const formattedCode = formatJson(code); + const copied = copiedText === code; + const html = + highlighted?.source === formattedCode ? highlighted.html : undefined; + + useEffect(() => { + let disposed = false; + + void highlighter + .then((instance) => + instance.codeToHtml(formattedCode, { + lang: "json", + themes: { + light: "github-light", + dark: "github-dark", + }, + defaultColor: false, + }), + ) + .then((result) => { + if (!disposed) { + setHighlighted({ source: formattedCode, html: result }); + } + }) + .catch(() => { + // Keep the readable, escaped source fallback if syntax highlighting cannot load. + }); + + return () => { + disposed = true; + }; + }, [formattedCode]); + + const copyJson = () => { + void copyToClipboard(code); + }; + + const copyButton = ( + + ); + + if (!html) { + return ( +
    +
    {copyButton}
    + {copied ? ( + + JSON copied + + ) : null} +
    +          {formattedCode}
    +        
    +
    + ); + } + + return ( +
    +
    {copyButton}
    + {copied ? ( + + JSON copied + + ) : null} +
    +
    + ); +} diff --git a/apps/local/src/components/receiver-controls.tsx b/apps/local/src/components/receiver-controls.tsx new file mode 100644 index 000000000..744209db5 --- /dev/null +++ b/apps/local/src/components/receiver-controls.tsx @@ -0,0 +1,160 @@ +import { ChevronDown, Moon, Sun, Trash2, X } from "lucide-react"; +import { useEffect, useId, useRef, useState } from "react"; +import { useTheme } from "next-themes"; +import type { ConnectionPresentation } from "@/lib/presentation.ts"; + +type ReceiverControlsProps = { + compact?: boolean; + connection: ConnectionPresentation; + retainedItemCount: number; + onClear: () => void; + onCloseSession?: () => void; + showStatusRole?: boolean; +}; + +/** Status and viewer-only actions kept together without exposing receiver mutations. */ +export function ReceiverControls({ + compact = false, + connection, + retainedItemCount, + onClear, + onCloseSession, + showStatusRole = true, +}: ReceiverControlsProps) { + const [isOpen, setIsOpen] = useState(false); + const menuId = useId(); + const rootRef = useRef(null); + const { resolvedTheme, setTheme } = useTheme(); + const isDark = resolvedTheme === "dark"; + + useEffect(() => { + const closeOnOutsidePointer = (event: MouseEvent) => { + if (!rootRef.current?.contains(event.target as Node)) { + setIsOpen(false); + } + }; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setIsOpen(false); + } + }; + document.addEventListener("mousedown", closeOnOutsidePointer); + document.addEventListener("keydown", closeOnEscape); + return () => { + document.removeEventListener("mousedown", closeOnOutsidePointer); + document.removeEventListener("keydown", closeOnEscape); + }; + }, []); + + const dotClassName = + connection.tone === "success" + ? "bg-emerald-500 shadow-[0_0_10px_oklch(0.72_0.19_160)]" + : connection.tone === "warning" + ? "bg-amber-500" + : "bg-muted-foreground"; + + return ( +
    + {compact ? ( + + ) : ( +
    + + + + {connection.label.replace(" to local receiver", "")} + + {showStatusRole ? ( + {connection.label} + ) : null} + + +
    + )} + {isOpen ? ( +
    + + + {onCloseSession ? ( + + ) : null} +
    + ) : null} +
    + ); +} diff --git a/apps/local/src/components/retention-settings.tsx b/apps/local/src/components/retention-settings.tsx new file mode 100644 index 000000000..1f966fc23 --- /dev/null +++ b/apps/local/src/components/retention-settings.tsx @@ -0,0 +1,128 @@ +import { type FormEvent, useEffect, useState } from "react"; +import { + isValidMaxEnvelopes, + MAX_ENVELOPES, +} from "../../../../packages/cli/src/commands/local/retention-limits.ts"; + +type Props = { + limit: number; + count: number; + streamUrl: string; + editable: boolean; + unavailableReason?: string; +}; +/** Envelope-count controls keep existing viewer state and the EventSource intact. */ +export function RetentionSettings({ + limit, + count, + streamUrl, + editable, + unavailableReason, +}: Props) { + const [draft, setDraft] = useState(String(limit)); + const [error, setError] = useState(); + const [saving, setSaving] = useState(false); + useEffect(() => setDraft(String(limit)), [limit]); + async function save(event: FormEvent) { + event.preventDefault(); + const value = /^\d+$/.test(draft) ? Number(draft) : NaN; + if (!isValidMaxEnvelopes(value)) { + setError(`Enter an integer between 1 and ${MAX_ENVELOPES}.`); + return; + } + if (value < count) { + setError( + `Cannot lower the limit below ${count} displayed envelopes. Clear envelopes first.`, + ); + return; + } + const url = new URL(streamUrl); + url.pathname = /^\/_local\/v1\/sessions\/[^/]+\/stream$/.test(url.pathname) + ? url.pathname.replace(/\/stream$/, "/ui/retention") + : "/retention"; + setSaving(true); + setError(undefined); + try { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ maxEnvelopes: value }), + signal: AbortSignal.timeout(5000), + }); + const body: unknown = await response.json(); + if (!response.ok) { + const detail = + typeof body === "object" && + body !== null && + "error" in body && + typeof body.error === "string" + ? body.error + : "Could not update retention. Existing envelopes were preserved."; + setError(detail); + return; + } + if ( + typeof body !== "object" || + body === null || + !("maxEnvelopes" in body) || + !isValidMaxEnvelopes(body.maxEnvelopes) + ) + throw new Error("Invalid retention response"); + // Ordered stream metadata is authoritative. A delayed HTTP response must + // not overwrite a newer CLI change or a different receiver's settings. + } catch { + setError( + "Could not update retention. Existing envelopes were preserved.", + ); + } finally { + setSaving(false); + } + } + return ( +
    + + Retention limit: {limit.toLocaleString()} envelopes ·{" "} + {count.toLocaleString()} displayed + + {editable ? ( +
    + + setDraft(event.target.value)} + className="h-8 w-28 border border-border bg-background px-2" + /> + +

    + Existing envelopes stay available. A lower limit requires clearing + enough envelopes first. +

    + {error ? ( +

    + {error} +

    + ) : null} +
    + ) : ( +

    + {unavailableReason ?? + "Change retention from a locally served viewer or the CLI."} +

    + )} +
    + ); +} diff --git a/apps/local/src/components/theme-provider.tsx b/apps/local/src/components/theme-provider.tsx new file mode 100644 index 000000000..e96e3bd58 --- /dev/null +++ b/apps/local/src/components/theme-provider.tsx @@ -0,0 +1,6 @@ +import type { ThemeProviderProps } from "next-themes"; +import { ThemeProvider as NextThemesProvider } from "next-themes"; + +export function ThemeProvider({ children, ...props }: ThemeProviderProps) { + return {children}; +} diff --git a/apps/local/src/components/theme-toggle.tsx b/apps/local/src/components/theme-toggle.tsx new file mode 100644 index 000000000..91d9a1317 --- /dev/null +++ b/apps/local/src/components/theme-toggle.tsx @@ -0,0 +1,21 @@ +import { Moon, Sun } from "lucide-react"; +import { useTheme } from "next-themes"; +import { Button } from "@/components/ui/button.tsx"; + +export function ThemeToggle() { + const { resolvedTheme, setTheme } = useTheme(); + const isDark = resolvedTheme === "dark"; + + return ( + + ); +} diff --git a/apps/local/src/components/trace-waterfall.test.tsx b/apps/local/src/components/trace-waterfall.test.tsx new file mode 100644 index 000000000..038fe9aa1 --- /dev/null +++ b/apps/local/src/components/trace-waterfall.test.tsx @@ -0,0 +1,63 @@ +import { afterEach, expect, test } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { decodeEnvelope } from "@/lib/spotlight.ts"; +import { buildTraceGroups } from "@/lib/trace-model.ts"; +import { TraceWaterfall } from "./trace-waterfall.tsx"; + +afterEach(cleanup); + +test.each(["internal_error", "error"])( + "styles status-only %s failures without affecting healthy children", + (status) => { + const items = decodeEnvelope( + JSON.stringify([ + {}, + [ + [ + { type: "transaction" }, + { + transaction: "GET /failed", + start_timestamp: 1, + timestamp: 2, + contexts: { + trace: { trace_id: "trace", span_id: "root", status }, + }, + spans: [ + { + span_id: "healthy", + parent_span_id: "root", + op: "db.query", + status: "ok", + start_timestamp: 1.1, + timestamp: 1.2, + }, + { + span_id: "failed", + parent_span_id: "root", + op: "db.query", + status, + start_timestamp: 1.3, + timestamp: 1.4, + }, + ], + }, + ], + ], + ]), + "envelope", + ); + const trace = buildTraceGroups(items)[0]; + expect(trace).toBeDefined(); + if (!trace) throw new Error("Expected trace"); + render(); + expect(screen.getByTestId("waterfall-bar-root").className).toContain( + "bg-red-500", + ); + expect(screen.getByTestId("waterfall-bar-failed").className).toContain( + "bg-red-500", + ); + expect(screen.getByTestId("waterfall-bar-healthy").className).not.toContain( + "bg-red-500", + ); + }, +); diff --git a/apps/local/src/components/trace-waterfall.tsx b/apps/local/src/components/trace-waterfall.tsx new file mode 100644 index 000000000..992c6dbe6 --- /dev/null +++ b/apps/local/src/components/trace-waterfall.tsx @@ -0,0 +1,279 @@ +import type { TraceGroup, TraceSpan } from "@/lib/trace-model.ts"; + +type TraceWaterfallProps = { + compact?: boolean; + trace: TraceGroup; +}; + +type PositionedSpan = { + span: TraceSpan; + depth: number; + isLastChild: boolean; +}; + +function formatDuration(durationMs: number | undefined): string { + if (durationMs === undefined) return "—"; + return `${durationMs.toFixed(durationMs < 10 ? 2 : 0)}ms`; +} + +function getSpanPosition( + span: TraceSpan, + trace: TraceGroup, +): { left: string; width: string } | undefined { + if ( + trace.startTimestamp === undefined || + trace.durationMs === undefined || + trace.durationMs <= 0 || + span.startTimestamp === undefined + ) { + return undefined; + } + + const elapsedMs = (span.startTimestamp - trace.startTimestamp) * 1_000; + const left = Math.max(0, Math.min(100, (elapsedMs / trace.durationMs) * 100)); + const width = Math.max( + 1, + Math.min(100 - left, ((span.durationMs ?? 0) / trace.durationMs) * 100), + ); + + return { left: `${left}%`, width: `${width}%` }; +} + +function flattenSpans(spans: TraceSpan[], depth = 0): PositionedSpan[] { + return spans.flatMap((span, index) => [ + { span, depth, isLastChild: index === spans.length - 1 }, + ...flattenSpans(span.children, depth + 1), + ]); +} + +function isFailingSpan(span: TraceSpan): boolean { + const { level, statusCode, spanStatus } = span.metadata; + return ( + level === "error" || + level === "fatal" || + (statusCode !== undefined && statusCode >= 500) || + (spanStatus !== undefined && spanStatus !== "ok") + ); +} + +function SpanRow({ + positionedSpan, + trace, + index, +}: { + positionedSpan: PositionedSpan; + trace: TraceGroup; + index: number; +}) { + const { span, depth, isLastChild } = positionedSpan; + const position = getSpanPosition(span, trace); + const failed = isFailingSpan(span); + + return ( +
    +
    + {depth > 0 ? ( + <> +
    +
    + + ); +} + +/** A compact, read-only timeline for the spans contained in one local trace. */ +export function TraceWaterfall({ + compact = false, + trace, +}: TraceWaterfallProps) { + const capturedItemLabel = `${trace.itemCount} captured item${trace.itemCount === 1 ? "" : "s"} in this trace`; + + if (trace.spans.length === 0) { + return ( +
    + {compact ? null : ( +
    +

    + Trace +

    +
    +

    {trace.title}

    + + {formatDuration(trace.durationMs)} + +
    +
    + {capturedItemLabel} + Trace ID {trace.id.slice(0, 8)} +
    +
    + )} + {compact ? ( +

    + {capturedItemLabel} +

    + ) : null} +

    + This trace has no timestamped spans to display yet. +

    +
    + ); + } + + const spans = flattenSpans(trace.roots); + + return ( +
    + {compact ? ( +

    + {capturedItemLabel} +

    + ) : ( +
    +

    + Trace +

    +
    +

    {trace.title}

    + + {formatDuration(trace.durationMs)} + +
    +
    + + {trace.spans.length} span{trace.spans.length === 1 ? "" : "s"} + + {trace.errorCount > 0 ? ( + + {trace.errorCount} error{trace.errorCount === 1 ? "" : "s"} + + ) : null} + {trace.logCount > 0 ? ( + + {trace.logCount} log{trace.logCount === 1 ? "" : "s"} + + ) : null} + Trace ID {trace.id.slice(0, 8)} +
    +
    + )} +
    +
    +
    + Span +
    +
    + + Timeline + + +
    +
    + Duration +
    +
    + {spans.map((positionedSpan, index) => ( + + ))} +
    +
    + ); +} diff --git a/apps/local/src/components/trace-workspace.tsx b/apps/local/src/components/trace-workspace.tsx new file mode 100644 index 000000000..b484f4dc4 --- /dev/null +++ b/apps/local/src/components/trace-workspace.tsx @@ -0,0 +1,123 @@ +import type { TraceGroup } from "../lib/trace-model"; +import { TraceWaterfall } from "./trace-waterfall"; + +type TraceWorkspaceProps = { + emptyState?: { title: string; description: string }; + onSelect: (traceId: string) => void; + selectedTraceId?: string; + traces: TraceGroup[]; +}; + +function formatDuration(durationMs: number | undefined) { + if (durationMs === undefined) { + return "—"; + } + + if (durationMs < 1_000) { + return `${Math.round(durationMs)} ms`; + } + + return `${(durationMs / 1_000).toFixed(2)} s`; +} + +export function TraceWorkspace({ + emptyState = { + title: "No traces captured", + description: + "Transactions and spans from your receiver will appear here as a single trace.", + }, + onSelect, + selectedTraceId, + traces, +}: TraceWorkspaceProps) { + const selectedTrace = + traces.find((trace) => trace.id === selectedTraceId) ?? traces[0]; + + if (!selectedTrace) { + return ( +
    +
    +

    + {emptyState.title} +

    +

    + {emptyState.description} +

    +
    +
    + ); + } + + return ( +
    + + +
    + +
    +
    + ); +} diff --git a/apps/local/src/components/ui/badge.tsx b/apps/local/src/components/ui/badge.tsx new file mode 100644 index 000000000..f99f43d98 --- /dev/null +++ b/apps/local/src/components/ui/badge.tsx @@ -0,0 +1,34 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import type * as React from "react"; +import { cn } from "@/lib/utils.ts"; + +const badgeVariants = cva( + "inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium whitespace-nowrap", + { + variants: { + variant: { + default: "border-transparent bg-secondary text-secondary-foreground", + success: + "border-emerald-500/20 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300", + warning: + "border-amber-500/25 bg-amber-500/10 text-amber-800 dark:text-amber-300", + destructive: "border-destructive/20 bg-destructive/10 text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +function Badge({ + className, + variant, + ...props +}: React.ComponentProps<"span"> & VariantProps) { + return ( + + ); +} + +export { Badge }; diff --git a/apps/local/src/components/ui/button.tsx b/apps/local/src/components/ui/button.tsx new file mode 100644 index 000000000..4ac043a49 --- /dev/null +++ b/apps/local/src/components/ui/button.tsx @@ -0,0 +1,48 @@ +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import type * as React from "react"; +import { cn } from "@/lib/utils.ts"; + +const buttonVariants = cva( + "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + ghost: "hover:bg-accent hover:text-accent-foreground", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + }, + size: { + default: "h-9 px-4 py-2", + icon: "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean; + }) { + const Comp = asChild ? Slot : "button"; + + return ( + + ); +} + +export { Button }; diff --git a/apps/local/src/components/ui/card.tsx b/apps/local/src/components/ui/card.tsx new file mode 100644 index 000000000..fa28ccea1 --- /dev/null +++ b/apps/local/src/components/ui/card.tsx @@ -0,0 +1,38 @@ +import type * as React from "react"; +import { cn } from "@/lib/utils.ts"; + +function Card({ className, ...props }: React.ComponentProps<"section">) { + return ( +
    + ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ); +} + +function CardTitle({ className, ...props }: React.ComponentProps<"h2">) { + return ( +

    + ); +} + +function CardDescription({ className, ...props }: React.ComponentProps<"p">) { + return ( +

    + ); +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return

    ; +} + +export { Card, CardContent, CardDescription, CardHeader, CardTitle }; diff --git a/apps/local/src/components/ui/command.tsx b/apps/local/src/components/ui/command.tsx new file mode 100644 index 000000000..888ebee4c --- /dev/null +++ b/apps/local/src/components/ui/command.tsx @@ -0,0 +1,184 @@ +"use client"; + +import * as React from "react"; +import { Command as CommandPrimitive } from "cmdk"; +import { SearchIcon } from "lucide-react"; +import { cn } from "@/lib/utils.ts"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog.tsx"; + +function Command({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandDialog({ + title = "Command Palette", + description = "Search for a command to run...", + children, + className, + showCloseButton = true, + ...props +}: React.ComponentProps & { + title?: string; + description?: string; + className?: string; + showCloseButton?: boolean; +}) { + return ( + + + + {title} + {description} + + + {children} + + + + ); +} + +function CommandInput({ + className, + ...props +}: React.ComponentProps) { + return ( +
    + + +
    + ); +} + +function CommandList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandEmpty({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ); +} + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +}; diff --git a/apps/local/src/components/ui/dialog.tsx b/apps/local/src/components/ui/dialog.tsx new file mode 100644 index 000000000..e480ddd77 --- /dev/null +++ b/apps/local/src/components/ui/dialog.tsx @@ -0,0 +1,156 @@ +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { XIcon } from "lucide-react"; +import { cn } from "@/lib/utils.ts"; + +import { Button } from "@/components/ui/button.tsx"; + +function Dialog({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean; +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ); +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ); +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<"div"> & { + showCloseButton?: boolean; +}) { + return ( +
    + {children} + {showCloseButton && ( + + + + )} +
    + ); +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +}; diff --git a/apps/local/src/index.css b/apps/local/src/index.css new file mode 100644 index 000000000..2dd5dfc1f --- /dev/null +++ b/apps/local/src/index.css @@ -0,0 +1,97 @@ +@import "tailwindcss"; + +@custom-variant dark (&:is(.dark *)); + +:root { + --background: oklch(0.985 0.002 285); + --foreground: oklch(0.18 0.02 285); + --card: oklch(1 0 0); + --card-foreground: oklch(0.18 0.02 285); + --primary: oklch(0.56 0.25 292); + --primary-foreground: oklch(0.99 0.005 285); + --secondary: oklch(0.95 0.015 285); + --secondary-foreground: oklch(0.3 0.025 285); + --muted: oklch(0.95 0.012 285); + --muted-foreground: oklch(0.48 0.02 285); + --accent: oklch(0.94 0.02 285); + --accent-foreground: oklch(0.25 0.025 285); + --destructive: oklch(0.59 0.22 25); + --border: oklch(0.9 0.012 285); + --input: oklch(0.9 0.012 285); + --ring: oklch(0.56 0.25 292); + --radius: 0.5rem; +} + +.dark { + --background: oklch(0.16 0.018 285); + --foreground: oklch(0.96 0.008 285); + --card: oklch(0.2 0.018 285); + --card-foreground: oklch(0.96 0.008 285); + --primary: oklch(0.72 0.22 292); + --primary-foreground: oklch(0.2 0.018 285); + --secondary: oklch(0.27 0.02 285); + --secondary-foreground: oklch(0.95 0.008 285); + --muted: oklch(0.25 0.018 285); + --muted-foreground: oklch(0.7 0.018 285); + --accent: oklch(0.3 0.022 285); + --accent-foreground: oklch(0.96 0.008 285); + --destructive: oklch(0.7 0.2 25); + --border: oklch(0.31 0.018 285); + --input: oklch(0.31 0.018 285); + --ring: oklch(0.72 0.22 292); +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); +} + +@layer base { + * { + @apply border-border; + } + + html { + @apply bg-background; + } + + body { + @apply min-w-80 bg-background text-foreground antialiased; + } +} + +@layer components { + .json-view pre { + @apply m-0 break-words bg-transparent p-0 font-mono text-inherit whitespace-pre-wrap; + } + + .json-view .shiki { + background-color: transparent !important; + padding: 0 !important; + } + + .json-view .shiki span { + color: var(--shiki-light); + } + + .dark .json-view .shiki span { + color: var(--shiki-dark); + } +} diff --git a/apps/local/src/lib/presentation.test.ts b/apps/local/src/lib/presentation.test.ts new file mode 100644 index 000000000..a1b9617ba --- /dev/null +++ b/apps/local/src/lib/presentation.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "vitest"; +import { getConnectionPresentation } from "./presentation.ts"; + +describe("getConnectionPresentation", () => { + test("describes an active local receiver as connected", () => { + expect(getConnectionPresentation("connected")).toEqual({ + label: "Connected to local receiver", + tone: "success", + }); + }); + + test("explains when no local stream has been provided", () => { + expect(getConnectionPresentation("missing")).toEqual({ + label: "Waiting for a local stream", + tone: "neutral", + }); + }); + + test("makes a failed receiver visible in the status control", () => { + expect(getConnectionPresentation("failed")).toEqual({ + label: "Receiver unavailable", + tone: "warning", + }); + }); +}); diff --git a/apps/local/src/lib/presentation.ts b/apps/local/src/lib/presentation.ts new file mode 100644 index 000000000..ccadd87ce --- /dev/null +++ b/apps/local/src/lib/presentation.ts @@ -0,0 +1,31 @@ +export type ConnectionState = + | "connecting" + | "connected" + | "closed" + | "reconnecting" + | "missing" + | "failed"; + +export type ConnectionPresentation = { + label: string; + tone: "neutral" | "success" | "warning"; +}; + +export function getConnectionPresentation( + connection: ConnectionState, +): ConnectionPresentation { + switch (connection) { + case "connected": + return { label: "Connected to local receiver", tone: "success" }; + case "closed": + return { label: "Telemetry session closed", tone: "neutral" }; + case "connecting": + return { label: "Connecting to local receiver", tone: "neutral" }; + case "reconnecting": + return { label: "Reconnecting to local receiver", tone: "warning" }; + case "missing": + return { label: "Waiting for a local stream", tone: "neutral" }; + case "failed": + return { label: "Receiver unavailable", tone: "warning" }; + } +} diff --git a/apps/local/src/lib/spotlight.test.ts b/apps/local/src/lib/spotlight.test.ts new file mode 100644 index 000000000..a6eb99e20 --- /dev/null +++ b/apps/local/src/lib/spotlight.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, test } from "vitest"; +import { + appendBounded, + DEFAULT_STREAM_URL, + decodeEnvelope, + parseStreamEndpoint, + getPreferredStreamUrl, + resolveInitialStreamUrl, + getStreamUrlFromHash, + getStreamUrlFromStorage, +} from "./spotlight.js"; + +describe("getStreamUrlFromHash", () => { + test("classifies the default stream as loopback", () => { + expect(DEFAULT_STREAM_URL).toBe("http://localhost:8969/stream"); + expect(parseStreamEndpoint(DEFAULT_STREAM_URL)).toEqual({ + url: DEFAULT_STREAM_URL, + kind: "loopback", + }); + }); + + test("accepts an HTTPS remote stream with a query token", () => { + expect( + parseStreamEndpoint("https://receiver.example/stream?token=abc"), + ).toEqual({ + url: "https://receiver.example/stream?token=abc", + kind: "remote", + }); + }); + + test.each([ + "http://receiver.example/stream", + "https://user:pass@receiver.example/stream", + "https://receiver.example/other", + "https://receiver.example/stream#fragment", + ])("rejects unsafe custom endpoint %s", (url) => { + expect(parseStreamEndpoint(url)).toBeUndefined(); + }); + + test("accepts a loopback stream URL", () => { + expect( + getStreamUrlFromHash("#stream=http%3A%2F%2Flocalhost%3A8969%2Fstream"), + ).toBe("http://localhost:8969/stream"); + }); + + test("accepts a loopback daemon session stream with a read capability", () => { + const url = + "http://127.0.0.1:8969/_local/v1/sessions/session/stream?cap=read"; + expect(parseStreamEndpoint(url)).toEqual({ url, kind: "loopback" }); + }); + + test("rejects a non-loopback stream URL", () => { + expect( + getStreamUrlFromHash("#stream=http%3A%2F%2Fexample.com%2Fstream"), + ).toBeUndefined(); + }); + + test("restores a valid saved loopback stream URL", () => { + expect(getStreamUrlFromStorage("http://127.0.0.1:8969/stream")).toBe( + "http://127.0.0.1:8969/stream", + ); + }); + + test("rejects a saved non-loopback stream URL", () => { + expect( + getStreamUrlFromStorage("http://example.com/stream"), + ).toBeUndefined(); + }); + + test("prefers a fresh CLI fragment over a saved stream URL", () => { + expect( + getPreferredStreamUrl( + "#stream=http%3A%2F%2Flocalhost%3A9000%2Fstream", + "http://127.0.0.1:8969/stream", + ), + ).toBe("http://localhost:9000/stream"); + }); + + test("uses CLI pairing, saved endpoints, then the default in priority order", () => { + expect( + resolveInitialStreamUrl( + "#stream=http%3A%2F%2Flocalhost%3A9000%2Fstream", + null, + null, + ), + ).toBe("http://localhost:9000/stream"); + expect( + resolveInitialStreamUrl("", "http://127.0.0.1:8970/stream", null), + ).toBe("http://127.0.0.1:8970/stream"); + expect(resolveInitialStreamUrl("", null, null)).toBe(DEFAULT_STREAM_URL); + }); +}); + +describe("decodeEnvelope", () => { + test("unpacks batched span and log items from the Local receiver", () => { + expect( + decodeEnvelope( + '[{},[[{"type":"span"},{"version":2,"items":[{"trace_id":"trace-123","span_id":"span-456","parent_span_id":"root-span","name":"agent.run","start_timestamp":1700000000,"end_timestamp":1700000000.004,"attributes":{"sentry.op":{"value":"gen_ai.invoke_agent","type":"string"}}}]}],[{"type":"log"},{"version":2,"items":[{"trace_id":"trace-123","timestamp":1700000000.005,"level":"error","body":"Fixture request failed"}]}]]]', + "event-1", + ), + ).toMatchObject([ + { + id: "event-1:0", + type: "span", + timestamp: 1700000000.004, + metadata: { + title: "agent.run", + traceId: "trace-123", + spanId: "span-456", + operation: "gen_ai.invoke_agent", + durationMs: 4, + }, + }, + { + id: "event-1:1", + type: "log", + timestamp: 1700000000.005, + metadata: { + title: "Fixture request failed", + traceId: "trace-123", + level: "error", + }, + }, + ]); + }); + + test("describes standalone spans and logs with their own payload fields", () => { + expect( + decodeEnvelope( + '[{},[[{"type":"span"},{"trace_id":"trace-123","span_id":"span-456","op":"db.query","description":"SELECT * FROM orders","start_timestamp":1700000000,"timestamp":1700000000.004}],[{"type":"log"},{"trace_id":"trace-123","level":"error","body":"Inventory lookup failed","timestamp":1700000000.005}]]]', + "event-1", + ), + ).toMatchObject([ + { + type: "span", + metadata: { + title: "SELECT * FROM orders", + traceId: "trace-123", + spanId: "span-456", + operation: "db.query", + durationMs: 4, + }, + }, + { + type: "log", + metadata: { + title: "Inventory lookup failed", + traceId: "trace-123", + level: "error", + }, + }, + ]); + }); + + test("keeps the decoded payload available for trace views", () => { + const [item] = decodeEnvelope( + '[{},[[{"type":"transaction"},{"transaction":"GET /orders/42","contexts":{"trace":{"trace_id":"trace-123","span_id":"root-span"}},"spans":[{"span_id":"db-span","parent_span_id":"root-span","op":"db.query","description":"SELECT * FROM orders","start_timestamp":1700000000.001,"timestamp":1700000000.003}]}]]]', + "event-1", + ); + + expect((item as { payload?: unknown }).payload).toEqual({ + transaction: "GET /orders/42", + contexts: { trace: { trace_id: "trace-123", span_id: "root-span" } }, + spans: [ + { + span_id: "db-span", + parent_span_id: "root-span", + op: "db.query", + description: "SELECT * FROM orders", + start_timestamp: 1700000000.001, + timestamp: 1700000000.003, + }, + ], + }); + }); + + test("turns envelope items into displayable feed entries", () => { + expect( + decodeEnvelope( + '[{"sdk":{"name":"sentry.node"}},[[{"type":"transaction"},{"transaction":"GET /api/users/42","start_timestamp":1700000000,"timestamp":1700000000.00668,"contexts":{"trace":{"trace_id":"trace-123","span_id":"span-456","op":"http.server","data":{"http.method":"GET","http.url":"http://127.0.0.1:3030/api/users/42","http.response.status_code":200,"sentry.origin":"auto.http.otel.http"}}}}]]]', + "event-1", + ), + ).toEqual([ + { + id: "event-1:0", + type: "transaction", + timestamp: 1700000000.00668, + metadata: { + title: "GET /api/users/42", + method: "GET", + route: "/api/users/42", + statusCode: 200, + durationMs: 6.68, + traceId: "trace-123", + spanId: "span-456", + operation: "http.server", + origin: "auto.http.otel.http", + }, + text: '{\n "transaction": "GET /api/users/42",\n "start_timestamp": 1700000000,\n "timestamp": 1700000000.00668,\n "contexts": {\n "trace": {\n "trace_id": "trace-123",\n "span_id": "span-456",\n "op": "http.server",\n "data": {\n "http.method": "GET",\n "http.url": "http://127.0.0.1:3030/api/users/42",\n "http.response.status_code": 200,\n "sentry.origin": "auto.http.otel.http"\n }\n }\n }\n}', + payload: { + transaction: "GET /api/users/42", + start_timestamp: 1700000000, + timestamp: 1700000000.00668, + contexts: { + trace: { + trace_id: "trace-123", + span_id: "span-456", + op: "http.server", + data: { + "http.method": "GET", + "http.url": "http://127.0.0.1:3030/api/users/42", + "http.response.status_code": 200, + "sentry.origin": "auto.http.otel.http", + }, + }, + }, + }, + }, + ]); + }); + + test("rejects malformed envelopes", () => { + expect(() => decodeEnvelope("not json", "event-1")).toThrow( + "Invalid Sentry envelope", + ); + }); +}); + +describe("appendBounded", () => { + test("keeps the newest 500 items", () => { + const entries = Array.from({ length: 501 }, (_, index) => ({ + id: String(index), + type: "transaction", + text: String(index), + })); + + const result = appendBounded([], entries); + + expect(result).toHaveLength(500); + expect(result[0]?.id).toBe("1"); + expect(result.at(-1)?.id).toBe("500"); + }); +}); diff --git a/apps/local/src/lib/spotlight.ts b/apps/local/src/lib/spotlight.ts new file mode 100644 index 000000000..72dd3747d --- /dev/null +++ b/apps/local/src/lib/spotlight.ts @@ -0,0 +1,295 @@ +export const SENTRY_ENVELOPE_EVENT = "application/x-sentry-envelope"; +export const STREAM_STORAGE_KEY = "sentry.local.stream-url"; +export const REMOTE_STREAM_STORAGE_KEY = "sentry.local.remote-stream-url"; +export const DEFAULT_STREAM_URL = "http://localhost:8969/stream"; +const MAX_FEED_ITEMS = 500; + +export type StreamEndpointKind = "loopback" | "remote"; + +export type StreamEndpoint = { + url: string; + kind: StreamEndpointKind; +}; + +export type LocalFeedItem = { + id: string; + type: string; + timestamp?: number | string; + text: string; + payload: unknown; + metadata?: EventMetadata; +}; + +export type EventMetadata = { + title: string; + level?: string; + method?: string; + route?: string; + statusCode?: number; + spanStatus?: string; + durationMs?: number; + traceId?: string; + spanId?: string; + operation?: string; + origin?: string; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function getString(value: unknown): string | undefined { + return typeof value === "string" && value ? value : undefined; +} + +function getNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) + ? value + : undefined; +} + +function getAttributeString( + attributes: Record, + key: string, +): string | undefined { + const attribute = attributes[key]; + return isRecord(attribute) + ? getString(attribute.value) + : getString(attribute); +} + +function getRoute(url: string | undefined): string | undefined { + if (!url) { + return undefined; + } + try { + const parsed = new URL(url); + return `${parsed.pathname}${parsed.search}`; + } catch { + return url.startsWith("/") ? url : undefined; + } +} + +/** Derive display metadata from this payload without inheriting its parent event. */ +export function getEventMetadata( + payload: unknown, + type: string, +): EventMetadata { + const event = isRecord(payload) ? payload : {}; + const transaction = getString(event.transaction); + const description = getString(event.description); + const message = + getString(event.message) ?? getString(event.body) ?? getString(event.name); + const transactionMatch = transaction?.match(/^([A-Z]+)\s+(.+)$/); + const contexts = isRecord(event.contexts) ? event.contexts : {}; + const trace = isRecord(contexts.trace) ? contexts.trace : event; + const data = isRecord(trace.data) + ? trace.data + : isRecord(event.data) + ? event.data + : {}; + const attributes = isRecord(event.attributes) ? event.attributes : {}; + const request = isRecord(event.request) ? event.request : {}; + const method = + getString(request.method) ?? + getString(data["http.method"]) ?? + transactionMatch?.[1]; + const url = getString(request.url) ?? getString(data["http.url"]); + const route = getRoute(url) ?? transactionMatch?.[2]; + const startTimestamp = getNumber(event.start_timestamp); + const timestamp = + getNumber(event.timestamp) ?? getNumber(event.end_timestamp); + const durationMs = + startTimestamp !== undefined && timestamp !== undefined + ? Math.round((timestamp - startTimestamp) * 100_000) / 100 + : undefined; + + return { + title: transaction ?? description ?? message ?? route ?? type, + level: getString(event.level), + method, + route, + statusCode: + getNumber(request.status_code) ?? + getNumber(data["http.response.status_code"]), + spanStatus: getString(trace.status) ?? getString(event.status), + durationMs, + traceId: getString(trace.trace_id) ?? getString(event.trace_id), + spanId: getString(trace.span_id) ?? getString(event.span_id), + operation: + getString(trace.op) ?? + getString(event.op) ?? + getString(event.transaction_op) ?? + getAttributeString(attributes, "sentry.op"), + origin: + getString(data["sentry.origin"]) ?? + getString(event.origin) ?? + getAttributeString(attributes, "sentry.origin"), + }; +} + +function getBatchedPayloadItems(payload: unknown, type: string): unknown[] { + if ( + (type !== "span" && type !== "log") || + !isRecord(payload) || + !Array.isArray(payload.items) + ) { + return [payload]; + } + return payload.items.filter(isRecord); +} + +function getItemTimestamp(payload: unknown): number | string | undefined { + if (!isRecord(payload)) { + return undefined; + } + const timestamp = payload.timestamp ?? payload.end_timestamp; + return typeof timestamp === "number" || typeof timestamp === "string" + ? timestamp + : undefined; +} + +/** Whether a host permits loopback receiver controls. */ +export function isLoopbackHost(hostname: string): boolean { + const normalized = hostname.replace(/^\[|\]$/g, "").toLowerCase(); + return ( + normalized === "localhost" || + normalized === "127.0.0.1" || + normalized === "::1" + ); +} + +/** Normalize an explicit connection target and classify its persistence policy. */ +export function parseStreamEndpoint( + stream: string | null, +): StreamEndpoint | undefined { + if (!stream) { + return undefined; + } + try { + const url = new URL(stream); + const daemonStream = /^\/_local\/v1\/sessions\/[^/]+\/stream$/.test( + url.pathname, + ); + if ( + (!daemonStream && url.pathname !== "/stream") || + url.username || + url.password || + url.hash + ) { + return undefined; + } + if (isLoopbackHost(url.hostname)) { + if ( + (url.protocol !== "http:" && url.protocol !== "https:") || + (!daemonStream && url.search) || + (daemonStream && !url.searchParams.get("cap")) + ) { + return undefined; + } + return { url: url.toString(), kind: "loopback" }; + } + if (url.protocol === "https:") { + return { url: url.toString(), kind: "remote" }; + } + return undefined; + } catch { + return undefined; + } +} + +function normalizeLoopbackStreamUrl(stream: string | null): string | undefined { + const endpoint = parseStreamEndpoint(stream); + return endpoint?.kind === "loopback" ? endpoint.url : undefined; +} + +function normalizeRemoteStreamUrl(stream: string | null): string | undefined { + const endpoint = parseStreamEndpoint(stream); + return endpoint?.kind === "remote" ? endpoint.url : undefined; +} + +/** Read the CLI-provided stream endpoint without allowing arbitrary targets. */ +export function getStreamUrlFromHash(hash: string): string | undefined { + return normalizeLoopbackStreamUrl( + new URLSearchParams(hash.replace(/^#/, "")).get("stream"), + ); +} + +/** Validate a stream endpoint saved by an earlier CLI-launched tab. */ +export function getStreamUrlFromStorage( + stream: string | null, +): string | undefined { + return normalizeLoopbackStreamUrl(stream); +} + +/** Validate an explicit remote stream saved for the current browser session. */ +export function getRemoteStreamUrlFromStorage( + stream: string | null, +): string | undefined { + return normalizeRemoteStreamUrl(stream); +} + +/** Prefer a newly supplied CLI endpoint over a previously saved one. */ +export function getPreferredStreamUrl( + hash: string, + savedStream: string | null, +): string | undefined { + return getStreamUrlFromHash(hash) ?? getStreamUrlFromStorage(savedStream); +} + +/** Resolve the bare viewer's receiver in the same safe order as the UI. */ +export function resolveInitialStreamUrl( + hash: string, + savedLoopbackStream: string | null, + savedRemoteStream: string | null, +): string { + return ( + getStreamUrlFromHash(hash) ?? + getStreamUrlFromStorage(savedLoopbackStream) ?? + getRemoteStreamUrlFromStorage(savedRemoteStream) ?? + DEFAULT_STREAM_URL + ); +} + +/** Decode the event payload emitted by the local receiver's SSE endpoint. */ +export function decodeEnvelope(data: string, eventId: string): LocalFeedItem[] { + let parsed: unknown; + try { + parsed = JSON.parse(data); + } catch { + throw new Error("Invalid Sentry envelope"); + } + + if (!Array.isArray(parsed) || !Array.isArray(parsed[1])) { + throw new Error("Invalid Sentry envelope"); + } + + return parsed[1].flatMap((item, index) => { + if (!Array.isArray(item) || item.length < 2 || !isRecord(item[0])) { + return []; + } + const header = item[0]; + const type = typeof header.type === "string" ? header.type : "unknown"; + const payloads = getBatchedPayloadItems(item[1], type); + return payloads.map((payload, payloadIndex) => ({ + id: + payloads.length === 1 + ? `${eventId}:${index}` + : `${eventId}:${index}:${payloadIndex}`, + type, + timestamp: getItemTimestamp(payload), + text: JSON.stringify(payload, null, 2) ?? String(payload), + payload, + metadata: getEventMetadata(payload, type), + })); + }); +} + +/** Append received items while keeping the in-memory UI feed bounded. */ +export function appendBounded( + existing: readonly LocalFeedItem[], + incoming: readonly LocalFeedItem[], + limit = MAX_FEED_ITEMS, +): LocalFeedItem[] { + return [...existing, ...incoming].slice(-limit); +} diff --git a/apps/local/src/lib/telemetry-store.test.ts b/apps/local/src/lib/telemetry-store.test.ts new file mode 100644 index 000000000..ba22e9dfd --- /dev/null +++ b/apps/local/src/lib/telemetry-store.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from "vitest"; +import type { LocalFeedItem } from "./spotlight.ts"; +import { createLocalTelemetryStore } from "./telemetry-store.ts"; + +function item(overrides: Partial): LocalFeedItem { + return { + id: "event-1:0", + type: "transaction", + text: "{}", + payload: {}, + metadata: { title: "GET /checkout" }, + ...overrides, + }; +} + +describe("LocalTelemetryStore", () => { + test("retains every decoded item in the retained envelopes and grows without clearing", () => { + const store = createLocalTelemetryStore({ limit: 2 }); + const raw = (id: string) => item({ id, type: "envelope" }); + store.recordEnvelope(raw("one"), [ + item({ id: "one:0" }), + item({ id: "one:1" }), + item({ id: "one:2" }), + ]); + store.recordEnvelope(raw("two"), [item({ id: "two:0" })]); + expect(store.getSnapshot().items).toHaveLength(4); + store.setLimit(3); + expect(store.getSnapshot().items).toHaveLength(4); + store.recordEnvelope(raw("three"), [item({ id: "three:0" })]); + expect(store.getSnapshot().envelopes).toHaveLength(3); + store.recordEnvelope(raw("four"), [item({ id: "four:0" })]); + expect(store.getSnapshot().items.map((entry) => entry.id)).toEqual([ + "two:0", + "three:0", + "four:0", + ]); + }); + + test("indexes a bounded session by its observability view", () => { + const store = createLocalTelemetryStore({ limit: 3 }); + + store.append([ + item({ id: "transaction:0", type: "transaction" }), + item({ + id: "error:0", + type: "event", + metadata: { title: "Checkout failed", level: "error" }, + }), + item({ id: "log:0", type: "log" }), + item({ id: "feedback:0", type: "user_report" }), + ]); + + const snapshot = store.getSnapshot(); + expect(snapshot.items.map((entry) => entry.id)).toEqual([ + "error:0", + "log:0", + "feedback:0", + ]); + expect(snapshot.errors.map((entry) => entry.id)).toEqual(["error:0"]); + expect(snapshot.logs.map((entry) => entry.id)).toEqual(["log:0"]); + expect(snapshot.feedback.map((entry) => entry.id)).toEqual(["feedback:0"]); + expect(snapshot.traces).toEqual([]); + }); + + test("deduplicates replayed envelope items and clears derived indexes", () => { + const store = createLocalTelemetryStore(); + const transaction = item({ id: "transaction:0", type: "transaction" }); + + store.append([transaction, transaction]); + expect(store.getSnapshot().items).toHaveLength(1); + expect(store.getSnapshot().traces).toEqual([transaction]); + + store.clear(); + expect(store.getSnapshot()).toMatchObject({ + items: [], + errors: [], + traces: [], + logs: [], + feedback: [], + envelopes: [], + attachments: [], + profiles: [], + sdks: [], + ai: [], + }); + }); + + test("retains raw envelopes separately from decoded feed items", () => { + const store = createLocalTelemetryStore(); + const envelope = item({ + id: "envelope-1", + type: "envelope", + text: "[{}, []]", + }); + + store.recordEnvelope(envelope); + + expect(store.getSnapshot().items).toEqual([]); + expect(store.getSnapshot().envelopes).toEqual([envelope]); + }); +}); diff --git a/apps/local/src/lib/telemetry-store.ts b/apps/local/src/lib/telemetry-store.ts new file mode 100644 index 000000000..dae83dfc0 --- /dev/null +++ b/apps/local/src/lib/telemetry-store.ts @@ -0,0 +1,145 @@ +import { + DEFAULT_MAX_ENVELOPES, + isValidMaxEnvelopes, +} from "../../../../packages/cli/src/commands/local/retention-limits.ts"; +import type { LocalFeedItem } from "./spotlight.ts"; +import { isAiEvent, isErrorEvent } from "./workspace.ts"; + +export type LocalTelemetrySnapshot = { + items: LocalFeedItem[]; + errors: LocalFeedItem[]; + traces: LocalFeedItem[]; + logs: LocalFeedItem[]; + feedback: LocalFeedItem[]; + envelopes: LocalFeedItem[]; + attachments: LocalFeedItem[]; + profiles: LocalFeedItem[]; + sdks: LocalFeedItem[]; + ai: LocalFeedItem[]; +}; + +export type LocalTelemetryStore = { + append: (items: readonly LocalFeedItem[]) => void; + recordEnvelope: ( + envelope: LocalFeedItem, + items?: readonly LocalFeedItem[], + ) => void; + setLimit: (limit: number) => void; + clear: () => void; + getSnapshot: () => LocalTelemetrySnapshot; + subscribe: (listener: () => void) => () => void; +}; + +const EMPTY_SNAPSHOT: LocalTelemetrySnapshot = { + items: [], + errors: [], + traces: [], + logs: [], + feedback: [], + envelopes: [], + attachments: [], + profiles: [], + sdks: [], + ai: [], +}; + +function buildSnapshot( + items: LocalFeedItem[], + envelopes: LocalFeedItem[], +): LocalTelemetrySnapshot { + return { + items, + errors: items.filter(isErrorEvent), + traces: items.filter( + (item) => item.type === "transaction" || item.type === "span", + ), + logs: items.filter((item) => item.type === "log"), + feedback: items.filter( + (item) => item.type === "user_report" || item.type === "feedback", + ), + envelopes, + attachments: items.filter((item) => item.type === "attachment"), + profiles: items.filter( + (item) => item.type === "profile" || item.type === "profile_chunk", + ), + sdks: items.filter( + (item) => item.type === "client_report" || item.type === "session", + ), + ai: items.filter(isAiEvent), + }; +} + +/** + * Session-only, bounded telemetry state shared by the Local workspace views. + * It intentionally has no browser persistence: restarting a receiver starts a new session. + */ +export function createLocalTelemetryStore({ + limit = DEFAULT_MAX_ENVELOPES, +}: { limit?: number } = {}): LocalTelemetryStore { + if (!isValidMaxEnvelopes(limit)) + throw new Error("Invalid envelope retention limit."); + let snapshot = EMPTY_SNAPSHOT; + const envelopeItems = new Map(); + const listeners = new Set<() => void>(); + + function publish(items: LocalFeedItem[], envelopes = snapshot.envelopes) { + snapshot = buildSnapshot(items, envelopes); + for (const listener of listeners) { + listener(); + } + } + + return { + append(incoming) { + if (incoming.length === 0) { + return; + } + + const knownIds = new Set(snapshot.items.map((item) => item.id)); + const newItems = incoming.filter((item) => { + if (knownIds.has(item.id)) { + return false; + } + knownIds.add(item.id); + return true; + }); + if (newItems.length === 0) { + return; + } + + publish([...snapshot.items, ...newItems].slice(-limit)); + }, + setLimit(value) { + if (!isValidMaxEnvelopes(value)) + throw new Error("Invalid envelope retention limit."); + limit = value; + }, + recordEnvelope(envelope, decoded) { + if (snapshot.envelopes.some((item) => item.id === envelope.id)) { + return; + } + if (decoded) envelopeItems.set(envelope.id, decoded); + const envelopes = [...snapshot.envelopes, envelope].slice(-limit); + const retainedIds = new Set(envelopes.map((item) => item.id)); + for (const id of envelopeItems.keys()) + if (!retainedIds.has(id)) envelopeItems.delete(id); + publish( + decoded + ? envelopes.flatMap((item) => [...(envelopeItems.get(item.id) ?? [])]) + : snapshot.items, + envelopes, + ); + }, + clear() { + envelopeItems.clear(); + if (snapshot.items.length > 0 || snapshot.envelopes.length > 0) { + publish([], []); + } + }, + getSnapshot: () => snapshot, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; +} diff --git a/apps/local/src/lib/trace-model.test.ts b/apps/local/src/lib/trace-model.test.ts new file mode 100644 index 000000000..1e14985fd --- /dev/null +++ b/apps/local/src/lib/trace-model.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, test } from "vitest"; +import type { LocalFeedItem } from "./spotlight.js"; +import { buildTraceGroups, traceIdForItem } from "./trace-model.js"; + +function item(overrides: Partial): LocalFeedItem { + return { + id: "item-1", + type: "transaction", + text: "{}", + payload: {}, + metadata: { title: "GET /orders/42" }, + ...overrides, + }; +} + +describe("buildTraceGroups", () => { + test("uses a transaction ID as the trace fallback used by routing", () => { + expect( + traceIdForItem( + item({ + id: "transaction-without-trace", + metadata: { title: "GET /standalone" }, + }), + ), + ).toBe("transaction-without-trace"); + }); + + test("keeps a standalone transaction discoverable when it has no trace context", () => { + const traces = buildTraceGroups([ + { + id: "transaction-without-trace", + type: "transaction", + timestamp: 1_700_000_000_000, + text: "{}", + payload: {}, + metadata: { title: "GET /standalone" }, + }, + ]); + + expect(traces).toHaveLength(1); + expect(traces[0]?.id).toBe("transaction-without-trace"); + expect(traces[0]?.title).toBe("GET /standalone"); + }); + + test("builds a timestamped span tree and correlates trace errors and logs", () => { + const traces = buildTraceGroups([ + item({ + id: "transaction", + timestamp: 1_700_000_000.012, + metadata: { + title: "GET /orders/42", + traceId: "trace-42", + spanId: "root-span", + operation: "http.server", + durationMs: 12, + }, + payload: { + transaction: "GET /orders/42", + start_timestamp: 1_700_000_000, + timestamp: 1_700_000_000.012, + contexts: { + trace: { + trace_id: "trace-42", + span_id: "root-span", + op: "http.server", + }, + }, + spans: [ + { + span_id: "db-span", + parent_span_id: "root-span", + op: "db.query", + description: "SELECT * FROM orders", + start_timestamp: 1_700_000_000.002, + timestamp: 1_700_000_000.005, + }, + { + span_id: "client-span", + parent_span_id: "root-span", + op: "http.client", + description: "GET https://inventory.test/items/42", + start_timestamp: 1_700_000_000.006, + timestamp: 1_700_000_000.01, + }, + ], + }, + }), + item({ + id: "log", + type: "log", + metadata: { + title: "Inventory lookup finished", + traceId: "trace-42", + level: "info", + }, + }), + item({ + id: "error", + type: "event", + metadata: { + title: "Order failed", + traceId: "trace-42", + level: "error", + }, + }), + ]); + + expect(traces).toHaveLength(1); + expect(traces[0]).toMatchObject({ + id: "trace-42", + title: "GET /orders/42", + itemCount: 3, + logCount: 1, + errorCount: 1, + startTimestamp: 1_700_000_000, + endTimestamp: 1_700_000_000.012, + durationMs: 12, + }); + expect(traces[0]?.roots).toMatchObject([ + { + id: "root-span", + operation: "http.server", + children: [ + { id: "db-span", operation: "db.query", durationMs: 3 }, + { id: "client-span", operation: "http.client", durationMs: 4 }, + ], + }, + ]); + }); + + test("keeps a span visible when its declared parent is absent", () => { + const traces = buildTraceGroups([ + item({ + metadata: { + title: "orphaned work", + traceId: "trace-orphan", + spanId: "orphan-span", + }, + payload: { + start_timestamp: 1_700_000_000, + timestamp: 1_700_000_000.001, + contexts: { + trace: { + trace_id: "trace-orphan", + span_id: "orphan-span", + parent_span_id: "missing-parent", + op: "db.query", + }, + }, + }, + }), + ]); + + expect(traces[0]?.roots).toMatchObject([ + { id: "orphan-span", parentId: "missing-parent", operation: "db.query" }, + ]); + }); + + test("nests a standalone span using its top-level parent span id", () => { + const traces = buildTraceGroups([ + item({ + id: "root", + metadata: { + title: "POST /agent/run", + traceId: "trace-agent", + spanId: "root-span", + operation: "http.server", + }, + payload: { + transaction: "POST /agent/run", + start_timestamp: 1_700_000_000, + timestamp: 1_700_000_000.01, + contexts: { + trace: { + trace_id: "trace-agent", + span_id: "root-span", + op: "http.server", + }, + }, + }, + }), + item({ + id: "child", + type: "span", + metadata: { + title: "agent.run", + traceId: "trace-agent", + spanId: "child-span", + operation: "gen_ai.invoke_agent", + }, + payload: { + trace_id: "trace-agent", + span_id: "child-span", + parent_span_id: "root-span", + start_timestamp: 1_700_000_000.002, + end_timestamp: 1_700_000_000.006, + }, + }), + ]); + + expect(traces[0]?.roots).toMatchObject([ + { + id: "root-span", + children: [ + { id: "child-span", operation: "gen_ai.invoke_agent", durationMs: 4 }, + ], + }, + ]); + }); +}); diff --git a/apps/local/src/lib/trace-model.ts b/apps/local/src/lib/trace-model.ts new file mode 100644 index 000000000..0518800f2 --- /dev/null +++ b/apps/local/src/lib/trace-model.ts @@ -0,0 +1,270 @@ +import { + getEventMetadata, + type EventMetadata, + type LocalFeedItem, +} from "./spotlight.js"; +import { isErrorEvent } from "./workspace.js"; + +export type TraceSpan = { + id: string; + parentId?: string; + operation?: string; + description: string; + startTimestamp?: number; + endTimestamp?: number; + durationMs?: number; + item: LocalFeedItem; + metadata: EventMetadata; + children: TraceSpan[]; +}; + +export type TraceGroup = { + id: string; + title: string; + items: LocalFeedItem[]; + roots: TraceSpan[]; + spans: TraceSpan[]; + logCount: number; + errorCount: number; + itemCount: number; + startTimestamp?: number; + endTimestamp?: number; + durationMs?: number; +}; + +type MutableTraceGroup = Omit & { + spanMap: Map; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function getString(value: unknown): string | undefined { + return typeof value === "string" && value ? value : undefined; +} + +function getTimestamp(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +} + +function getTraceContext(payload: unknown): Record { + if (!isRecord(payload)) { + return {}; + } + const contexts = isRecord(payload.contexts) ? payload.contexts : {}; + return isRecord(contexts.trace) ? contexts.trace : {}; +} + +function getPayloadSpan( + payload: unknown, + item: LocalFeedItem, +): TraceSpan | undefined { + const event = isRecord(payload) ? payload : {}; + const trace = getTraceContext(payload); + const id = getString(trace.span_id) ?? item.metadata?.spanId; + if (!id) { + return undefined; + } + const startTimestamp = getTimestamp(event.start_timestamp); + const endTimestamp = + getTimestamp(event.timestamp) ?? + getTimestamp(event.end_timestamp) ?? + getTimestamp(item.timestamp); + const operation = getString(trace.op) ?? item.metadata?.operation; + const description = + getString(event.transaction) ?? + item.metadata?.route ?? + item.metadata?.title ?? + operation ?? + item.type; + + return { + id, + parentId: + getString(trace.parent_span_id) ?? getString(event.parent_span_id), + operation, + description, + startTimestamp, + endTimestamp, + durationMs: getDurationMs(startTimestamp, endTimestamp), + item, + metadata: item.metadata ?? getEventMetadata(payload, item.type), + children: [], + }; +} + +function getChildSpans(payload: unknown, item: LocalFeedItem): TraceSpan[] { + const event = isRecord(payload) ? payload : {}; + if (!Array.isArray(event.spans)) { + return []; + } + + return event.spans.flatMap((value) => { + if (!isRecord(value)) { + return []; + } + const id = getString(value.span_id); + if (!id) { + return []; + } + const startTimestamp = getTimestamp(value.start_timestamp); + const endTimestamp = getTimestamp(value.timestamp); + const operation = getString(value.op); + return [ + { + id, + parentId: getString(value.parent_span_id), + operation, + description: getString(value.description) ?? operation ?? id, + startTimestamp, + endTimestamp, + durationMs: getDurationMs(startTimestamp, endTimestamp), + item, + metadata: getEventMetadata(value, "span"), + children: [], + }, + ]; + }); +} + +function getDurationMs( + startTimestamp: number | undefined, + endTimestamp: number | undefined, +): number | undefined { + if ( + startTimestamp === undefined || + endTimestamp === undefined || + endTimestamp < startTimestamp + ) { + return undefined; + } + return Math.round((endTimestamp - startTimestamp) * 100_000) / 100; +} + +export function traceIdForItem(item: LocalFeedItem): string | undefined { + return ( + item.metadata?.traceId ?? + (item.type === "transaction" || item.type === "span" ? item.id : undefined) + ); +} + +function sortByTimestamp( + items: T[], +): T[] { + return items.sort( + (left, right) => + (left.startTimestamp ?? Infinity) - (right.startTimestamp ?? Infinity), + ); +} + +function updateTraceTiming(trace: MutableTraceGroup): void { + const timestamps = trace.items + .flatMap((item) => [getTimestamp(item.timestamp)]) + .concat( + [...trace.spanMap.values()].flatMap((span) => [ + span.startTimestamp, + span.endTimestamp, + ]), + ) + .filter((timestamp): timestamp is number => timestamp !== undefined); + + if (timestamps.length === 0) { + return; + } + + trace.startTimestamp = Math.min(...timestamps); + trace.endTimestamp = Math.max(...timestamps); + trace.durationMs = getDurationMs(trace.startTimestamp, trace.endTimestamp); +} + +/** Build a small, read-only trace model once for every view in Local. */ +export function buildTraceGroups( + items: readonly LocalFeedItem[], +): TraceGroup[] { + const traces = new Map(); + + for (const item of items) { + const traceId = traceIdForItem(item); + if (!traceId) { + continue; + } + + const trace: MutableTraceGroup = traces.get(traceId) ?? { + id: traceId, + title: item.metadata?.title ?? item.type, + items: [], + logCount: 0, + errorCount: 0, + spanMap: new Map(), + }; + if (!traces.has(traceId)) { + traces.set(traceId, trace); + } + + trace.items.push(item); + if (item.type === "log") { + trace.logCount += 1; + } + if (isErrorEvent(item)) { + trace.errorCount += 1; + } + + if (item.type === "transaction" || item.type === "span") { + const rootSpan = getPayloadSpan(item.payload, item); + if (rootSpan) { + trace.spanMap.set(rootSpan.id, rootSpan); + } + } + for (const childSpan of getChildSpans(item.payload, item)) { + trace.spanMap.set(childSpan.id, childSpan); + } + updateTraceTiming(trace); + } + + return [...traces.values()] + .map((trace) => { + const spans = sortByTimestamp([...trace.spanMap.values()]); + const roots: TraceSpan[] = []; + for (const span of spans) { + span.children = []; + } + for (const span of spans) { + const parent = span.parentId + ? trace.spanMap.get(span.parentId) + : undefined; + if (parent && parent !== span) { + parent.children.push(span); + } else { + roots.push(span); + } + } + for (const span of spans) { + sortByTimestamp(span.children); + } + + return { + id: trace.id, + title: trace.title, + items: trace.items, + roots: sortByTimestamp(roots), + spans, + logCount: trace.logCount, + errorCount: trace.errorCount, + itemCount: trace.items.length, + startTimestamp: trace.startTimestamp, + endTimestamp: trace.endTimestamp, + durationMs: trace.durationMs, + }; + }) + .sort( + (left, right) => (right.endTimestamp ?? 0) - (left.endTimestamp ?? 0), + ); +} diff --git a/apps/local/src/lib/utils.ts b/apps/local/src/lib/utils.ts new file mode 100644 index 000000000..a5ef19350 --- /dev/null +++ b/apps/local/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/apps/local/src/lib/workspace-navigation.ts b/apps/local/src/lib/workspace-navigation.ts new file mode 100644 index 000000000..f57e48d15 --- /dev/null +++ b/apps/local/src/lib/workspace-navigation.ts @@ -0,0 +1,143 @@ +import { + Activity, + Bot, + Boxes, + Bug, + FileArchive, + Gauge, + ListTree, + MessageSquareText, + Terminal, + type LucideIcon, +} from "lucide-react"; +import type { LocalFeedItem } from "./spotlight.ts"; +import type { LocalTelemetrySnapshot } from "./telemetry-store.ts"; +import type { WorkspaceView } from "./workspace.ts"; + +export type WorkspaceNavigationItem = { + id: WorkspaceView; + label: string; + singularLabel: string; + section: "Explore" | "Inspect"; + emptyState: { + title: string; + description: string; + }; + icon: LucideIcon; + getItems: (snapshot: LocalTelemetrySnapshot) => LocalFeedItem[]; +}; + +export const workspaceNavigation: WorkspaceNavigationItem[] = [ + { + id: "live", + label: "Live Activity", + singularLabel: "event", + section: "Explore", + emptyState: { + title: "Waiting for events", + description: "Incoming local telemetry will appear here.", + }, + icon: Activity, + getItems: (snapshot) => snapshot.items, + }, + { + id: "traces", + label: "Traces", + singularLabel: "trace", + section: "Explore", + emptyState: { + title: "No traces captured", + description: + "Transactions and spans from your receiver will appear here as a single trace.", + }, + icon: ListTree, + getItems: (snapshot) => snapshot.traces, + }, + { + id: "errors", + label: "Errors", + singularLabel: "error", + section: "Explore", + emptyState: { + title: "No errors captured", + description: + "Errors and failed requests from this receiver will appear here.", + }, + icon: Bug, + getItems: (snapshot) => snapshot.errors, + }, + { + id: "logs", + label: "Logs", + singularLabel: "log", + section: "Explore", + emptyState: { + title: "No logs captured", + description: + "Structured logs sent through the receiver will appear here.", + }, + icon: Terminal, + getItems: (snapshot) => snapshot.logs, + }, + { + id: "ai", + label: "AI", + singularLabel: "AI event", + section: "Explore", + emptyState: { + title: "No AI activity captured", + description: "AI spans and related telemetry will appear here.", + }, + icon: Bot, + getItems: (snapshot) => snapshot.ai, + }, + { + id: "envelopes", + label: "Envelopes", + singularLabel: "envelope", + section: "Inspect", + emptyState: { + title: "No envelopes received", + description: "Raw envelopes received by this viewer will appear here.", + }, + icon: FileArchive, + getItems: (snapshot) => snapshot.envelopes, + }, + { + id: "sdks", + label: "Sessions & SDKs", + singularLabel: "session or client report", + section: "Inspect", + emptyState: { + title: "No sessions or client reports", + description: "SDK sessions and client reports will appear here.", + }, + icon: Boxes, + getItems: (snapshot) => snapshot.sdks, + }, + { + id: "feedback", + label: "Feedback", + singularLabel: "feedback item", + section: "Inspect", + emptyState: { + title: "No feedback received", + description: + "User feedback submitted through supported SDKs will appear here.", + }, + icon: MessageSquareText, + getItems: (snapshot) => snapshot.feedback, + }, + { + id: "profiles", + label: "Profiles", + singularLabel: "profile", + section: "Inspect", + emptyState: { + title: "No profiles captured", + description: "Profiling data from supported SDKs will appear here.", + }, + icon: Gauge, + getItems: (snapshot) => snapshot.profiles, + }, +]; diff --git a/apps/local/src/lib/workspace-query.test.tsx b/apps/local/src/lib/workspace-query.test.tsx new file mode 100644 index 000000000..6cff806c8 --- /dev/null +++ b/apps/local/src/lib/workspace-query.test.tsx @@ -0,0 +1,55 @@ +import { act, renderHook } from "@testing-library/react"; +import { withNuqsTestingAdapter } from "nuqs/adapters/testing"; +import { describe, expect, test, vi } from "vitest"; +import { useWorkspaceQueryState } from "./workspace-query.ts"; + +describe("useWorkspaceQueryState", () => { + test("hydrates typed state and uses replace history for list controls", async () => { + const onUrlUpdate = vi.fn(); + const { result } = renderHook(() => useWorkspaceQueryState(), { + wrapper: withNuqsTestingAdapter({ + hasMemory: true, + onUrlUpdate, + searchParams: "?event=event-1&trace=trace-1&q=checkout", + }), + }); + + expect(result.current).toMatchObject({ + eventId: "event-1", + query: "checkout", + traceId: "trace-1", + }); + + await act(() => result.current.setSearchQuery("orders")); + + expect(onUrlUpdate).toHaveBeenLastCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ history: "replace" }), + queryString: "?event=event-1&trace=trace-1&q=orders", + }), + ); + }); + + test("uses push history for selected events and traces", async () => { + const onUrlUpdate = vi.fn(); + const { result } = renderHook(() => useWorkspaceQueryState(), { + wrapper: withNuqsTestingAdapter({ hasMemory: true, onUrlUpdate }), + }); + + await act(() => result.current.selectEvent("event-1")); + expect(onUrlUpdate).toHaveBeenLastCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ history: "push" }), + queryString: "?event=event-1", + }), + ); + + await act(() => result.current.selectTrace("trace-1")); + expect(onUrlUpdate).toHaveBeenLastCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ history: "push" }), + queryString: "?trace=trace-1", + }), + ); + }); +}); diff --git a/apps/local/src/lib/workspace-query.ts b/apps/local/src/lib/workspace-query.ts new file mode 100644 index 000000000..ae062f17d --- /dev/null +++ b/apps/local/src/lib/workspace-query.ts @@ -0,0 +1,27 @@ +import { parseAsString, useQueryStates } from "nuqs"; + +const workspaceQueryParsers = { + event: parseAsString, + trace: parseAsString, + q: parseAsString.withDefault(""), +}; + +export function useWorkspaceQueryState() { + const [state, setState] = useQueryStates(workspaceQueryParsers); + + return { + eventId: state.event, + query: state.q, + traceId: state.trace, + clearWorkspace: () => + setState({ event: null, q: null, trace: null }, { history: "replace" }), + resetWorkspace: () => + setState({ event: null, trace: null }, { history: "replace" }), + selectEvent: (eventId: string) => + setState({ event: eventId, trace: null }, { history: "push" }), + selectTrace: (traceId: string) => + setState({ event: null, trace: traceId }, { history: "push" }), + setSearchQuery: (query: string) => + setState({ q: query || null }, { history: "replace" }), + }; +} diff --git a/apps/local/src/lib/workspace.test.ts b/apps/local/src/lib/workspace.test.ts new file mode 100644 index 000000000..6ee09dbf9 --- /dev/null +++ b/apps/local/src/lib/workspace.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "vitest"; +import { + isAiEvent, + workspaceForItem, + workspaceFromPath, + workspacePath, +} from "./workspace.ts"; + +describe("workspace routes", () => { + test("maps retained events to their canonical workspaces", () => { + expect(workspaceForItem({ id: "raw", type: "envelope", text: "[]" })).toBe( + "envelopes", + ); + expect( + workspaceForItem({ + id: "error", + type: "event", + text: "{}", + metadata: { title: "Broken", level: "error" }, + }), + ).toBe("errors"); + expect( + workspaceForItem({ + id: "ai", + type: "span", + text: "{}", + metadata: { title: "Prompt", operation: "gen_ai.invoke_agent" }, + }), + ).toBe("ai"); + }); + + test("uses Live Activity for the compatible root and unknown paths", () => { + expect(workspaceFromPath("/")).toBe("live"); + expect(workspaceFromPath("/not-a-workspace")).toBe("live"); + expect(workspacePath("traces")).toBe("/traces"); + }); +}); + +describe("AI classification", () => { + test.each(["queue.wait", "auto.http.aiohttp", "email.send"])( + "keeps ordinary %s telemetry in traces", + (value) => { + const item = { + id: "span", + type: "span", + text: "{}", + metadata: { title: "work", operation: value, origin: value }, + }; + expect(isAiEvent(item)).toBe(false); + expect(workspaceForItem(item)).toBe("traces"); + }, + ); + test.each([ + "ai.chat", + "gen_ai.invoke_agent", + "llm.generate", + "auto.ai.openai", + ])("recognizes the AI namespace in %s", (value) => + expect( + isAiEvent({ + id: "span", + type: "span", + text: "{}", + metadata: { title: "Prompt", operation: value }, + }), + ).toBe(true), + ); +}); diff --git a/apps/local/src/lib/workspace.ts b/apps/local/src/lib/workspace.ts new file mode 100644 index 000000000..89a38a01d --- /dev/null +++ b/apps/local/src/lib/workspace.ts @@ -0,0 +1,117 @@ +import type { EventMetadata, LocalFeedItem } from "./spotlight.ts"; + +function getMetadata(item: LocalFeedItem): EventMetadata { + return item.metadata ?? { title: item.type }; +} + +export const workspaceViews = [ + "live", + "traces", + "errors", + "logs", + "ai", + "envelopes", + "sdks", + "feedback", + "profiles", +] as const; + +export type WorkspaceView = (typeof workspaceViews)[number]; + +export function workspacePath(view: WorkspaceView) { + return view === "live" ? "/live" : `/${view}`; +} + +export function workspaceFromPath(pathname: string): WorkspaceView { + if (pathname === "/") { + return "live"; + } + + const view = pathname.slice(1); + return workspaceViews.includes(view as WorkspaceView) + ? (view as WorkspaceView) + : "live"; +} + +export function isErrorEvent(item: LocalFeedItem) { + const { level, statusCode } = getMetadata(item); + return ( + level === "error" || + level === "fatal" || + (statusCode !== undefined && statusCode >= 500) + ); +} + +export function isAiEvent(item: LocalFeedItem) { + const metadata = getMetadata(item); + return [item.type, metadata.operation, metadata.origin].some( + (value) => + value !== undefined && + /(?:^|[.\s:_-])(?:ai|gen_ai|llm)(?:$|[.\s:_-])/i.test(value), + ); +} + +export function workspaceForItem(item: LocalFeedItem): WorkspaceView { + if (item.type === "envelope") { + return "envelopes"; + } + if (isAiEvent(item)) { + return "ai"; + } + if (isErrorEvent(item)) { + return "errors"; + } + if (item.type === "transaction" || item.type === "span") { + return "traces"; + } + if (item.type === "log") { + return "logs"; + } + if (item.type === "user_report" || item.type === "feedback") { + return "feedback"; + } + if (item.type === "profile" || item.type === "profile_chunk") { + return "profiles"; + } + if (item.type === "client_report" || item.type === "session") { + return "sdks"; + } + return "live"; +} + +export function matchesSearch(item: LocalFeedItem, query: string) { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) { + return true; + } + + const metadata = getMetadata(item); + return [ + item.type, + item.text, + metadata.title, + metadata.level, + metadata.method, + metadata.route, + metadata.traceId, + metadata.spanId, + metadata.operation, + metadata.origin, + ].some((value) => value?.toLowerCase().includes(normalizedQuery)); +} + +export function eventSearchValue(item: LocalFeedItem) { + const metadata = getMetadata(item); + return [ + item.type, + metadata.title, + metadata.level, + metadata.method, + metadata.route, + metadata.traceId, + metadata.operation, + metadata.origin, + ] + .filter(Boolean) + .join(" "); +} diff --git a/apps/local/src/main.tsx b/apps/local/src/main.tsx new file mode 100644 index 000000000..4e906e5f7 --- /dev/null +++ b/apps/local/src/main.tsx @@ -0,0 +1,20 @@ +import { Analytics } from "@vercel/analytics/react"; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import "./index.css"; +import { ThemeProvider } from "./components/theme-provider.tsx"; +import { LocalRouter } from "./router.tsx"; + +createRoot(document.getElementById("root")!).render( + + + + + + , +); diff --git a/apps/local/src/router.test.tsx b/apps/local/src/router.test.tsx new file mode 100644 index 000000000..315b1201b --- /dev/null +++ b/apps/local/src/router.test.tsx @@ -0,0 +1,37 @@ +import { render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { LocalRouter } from "./router.tsx"; + +class OpeningEventSource extends EventTarget { + onopen: ((event: Event) => void) | null = null; + onerror: ((event: Event) => void) | null = null; + + constructor(_url: string) { + super(); + queueMicrotask(() => this.onopen?.(new Event("open"))); + } + + close() {} +} + +describe("LocalRouter", () => { + beforeEach(() => { + window.history.replaceState(null, "", "/"); + vi.stubGlobal("EventSource", OpeningEventSource); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test("renders the compatible root route", async () => { + render(); + + expect( + ( + await screen.findByRole("button", { name: "Open live activity view" }) + ).getAttribute("aria-current"), + ).toBe("page"); + expect(screen.queryByRole("banner")).toBeNull(); + }); +}); diff --git a/apps/local/src/router.tsx b/apps/local/src/router.tsx new file mode 100644 index 000000000..ec070ec6a --- /dev/null +++ b/apps/local/src/router.tsx @@ -0,0 +1,34 @@ +import { createBrowserRouter, Navigate, RouterProvider } from "react-router"; +import { NuqsAdapter } from "nuqs/adapters/react"; +import App from "./App.tsx"; +import { LocalWorkspaceRoute } from "./routes/local-workspace-route.tsx"; + +function WorkspaceRoute() { + return ( + + + + ); +} + +const localRouter = createBrowserRouter([ + { path: "/", Component: WorkspaceRoute }, + { path: "/live", Component: WorkspaceRoute }, + { path: "/traces", Component: WorkspaceRoute }, + { path: "/errors", Component: WorkspaceRoute }, + { path: "/logs", Component: WorkspaceRoute }, + { path: "/ai", Component: WorkspaceRoute }, + { path: "/envelopes", Component: WorkspaceRoute }, + { path: "/sdks", Component: WorkspaceRoute }, + { path: "/feedback", Component: WorkspaceRoute }, + { path: "/profiles", Component: WorkspaceRoute }, + { path: "*", element: }, +]); + +export function LocalRouter() { + return ( + + + + ); +} diff --git a/apps/local/src/routes/local-workspace-context.ts b/apps/local/src/routes/local-workspace-context.ts new file mode 100644 index 000000000..565414d0b --- /dev/null +++ b/apps/local/src/routes/local-workspace-context.ts @@ -0,0 +1,24 @@ +import { createContext, useContext } from "react"; +import { type useWorkspaceQueryState } from "@/lib/workspace-query.ts"; +import type { WorkspaceView } from "@/lib/workspace.ts"; + +export type LocalWorkspaceRouteState = { + workspaceQuery: ReturnType; + workspaceView: WorkspaceView; + navigateToWorkspace: (view: WorkspaceView) => void; + navigateToCommandEvent: (view: WorkspaceView, eventId: string) => void; + navigateToCommandTrace: (view: WorkspaceView, traceId: string) => void; +}; + +export const LocalWorkspaceRouteContext = + createContext(null); + +export function useLocalWorkspaceRoute() { + const context = useContext(LocalWorkspaceRouteContext); + if (!context) { + throw new Error( + "useLocalWorkspaceRoute must be used within LocalWorkspaceRoute", + ); + } + return context; +} diff --git a/apps/local/src/routes/local-workspace-route.tsx b/apps/local/src/routes/local-workspace-route.tsx new file mode 100644 index 000000000..c0395984f --- /dev/null +++ b/apps/local/src/routes/local-workspace-route.tsx @@ -0,0 +1,56 @@ +import type { ReactNode } from "react"; +import { useLocation, useNavigate } from "react-router"; +import { useWorkspaceQueryState } from "@/lib/workspace-query.ts"; +import { + workspaceFromPath, + workspacePath, + type WorkspaceView, +} from "@/lib/workspace.ts"; +import { LocalWorkspaceRouteContext } from "./local-workspace-context.ts"; + +export function LocalWorkspaceRoute({ children }: { children: ReactNode }) { + const location = useLocation(); + const navigate = useNavigate(); + const workspaceQuery = useWorkspaceQueryState(); + + const navigateToWorkspace = (view: WorkspaceView) => { + navigate({ pathname: workspacePath(view), search: location.search }); + }; + + const navigateToCommandSelection = ( + view: WorkspaceView, + selection: { event?: string; trace?: string }, + ) => { + const search = new URLSearchParams(location.search); + search.delete("event"); + search.delete("q"); + search.delete("trace"); + if (selection.event) { + search.set("event", selection.event); + } + if (selection.trace) { + search.set("trace", selection.trace); + } + const query = search.toString(); + navigate({ + pathname: workspacePath(view), + search: query ? `?${query}` : "", + }); + }; + + return ( + + navigateToCommandSelection(view, { event: eventId }), + navigateToCommandTrace: (view, traceId) => + navigateToCommandSelection(view, { trace: traceId }), + }} + > + {children} + + ); +} diff --git a/apps/local/src/runtime-dependencies.test.ts b/apps/local/src/runtime-dependencies.test.ts new file mode 100644 index 000000000..8011010d9 --- /dev/null +++ b/apps/local/src/runtime-dependencies.test.ts @@ -0,0 +1,13 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, test } from "vitest"; + +const packageJson = JSON.parse( + readFileSync(resolve(process.cwd(), "package.json"), "utf8"), +) as { dependencies: Record }; + +describe("Local runtime dependencies", () => { + test("declares tslib for dialog dependencies that import its ESM helpers", () => { + expect(packageJson.dependencies.tslib).toBeDefined(); + }); +}); diff --git a/apps/local/src/test-setup.ts b/apps/local/src/test-setup.ts new file mode 100644 index 000000000..58eb88616 --- /dev/null +++ b/apps/local/src/test-setup.ts @@ -0,0 +1,19 @@ +class TestResizeObserver implements ResizeObserver { + disconnect() {} + + observe(_target: Element, _options?: ResizeObserverOptions) {} + + unobserve(_target: Element) {} +} + +if (!globalThis.ResizeObserver) { + Object.defineProperty(globalThis, "ResizeObserver", { + configurable: true, + value: TestResizeObserver, + writable: true, + }); +} + +if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = () => {}; +} diff --git a/apps/local/src/vercel-config.test.ts b/apps/local/src/vercel-config.test.ts new file mode 100644 index 000000000..2c9b4eaad --- /dev/null +++ b/apps/local/src/vercel-config.test.ts @@ -0,0 +1,25 @@ +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, test } from "vitest"; + +describe("Local Vercel configuration", () => { + test("installs dependencies cleanly and serves SPA deep links", () => { + const configPath = resolve(process.cwd(), "vercel.json"); + expect(existsSync(configPath)).toBe(true); + if (!existsSync(configPath)) { + return; + } + + const config = JSON.parse(readFileSync(configPath, "utf8")) as { + installCommand?: string; + rewrites?: Array<{ destination?: string; source?: string }>; + }; + expect(config.installCommand).toBe( + "pnpm install --force --frozen-lockfile", + ); + expect(config.rewrites ?? []).toContainEqual({ + source: "/(.*)", + destination: "/index.html", + }); + }); +}); diff --git a/apps/local/tsconfig.app.json b/apps/local/tsconfig.app.json new file mode 100644 index 000000000..a76bcf6ce --- /dev/null +++ b/apps/local/tsconfig.app.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "allowArbitraryExtensions": true, + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"] +} diff --git a/apps/local/tsconfig.json b/apps/local/tsconfig.json new file mode 100644 index 000000000..d0b2bca50 --- /dev/null +++ b/apps/local/tsconfig.json @@ -0,0 +1,14 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ], + "compilerOptions": { + "ignoreDeprecations": "6.0", + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + } +} diff --git a/apps/local/tsconfig.node.json b/apps/local/tsconfig.node.json new file mode 100644 index 000000000..8455dcbc2 --- /dev/null +++ b/apps/local/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "module": "nodenext", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/apps/local/vercel.json b/apps/local/vercel.json new file mode 100644 index 000000000..2736ab335 --- /dev/null +++ b/apps/local/vercel.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "installCommand": "pnpm install --force --frozen-lockfile", + "rewrites": [ + { + "source": "/(.*)", + "destination": "/index.html" + } + ] +} diff --git a/apps/local/vite.config.ts b/apps/local/vite.config.ts new file mode 100644 index 000000000..6a2374b60 --- /dev/null +++ b/apps/local/vite.config.ts @@ -0,0 +1,22 @@ +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import tailwindcss from "@tailwindcss/vite"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [react(), tailwindcss()], + resolve: { + alias: { + "@": resolve(dirname(fileURLToPath(import.meta.url)), "./src"), + }, + }, + server: { + port: 5173, + strictPort: true, + }, + test: { + environment: "jsdom", + setupFiles: "./src/test-setup.ts", + }, +}); diff --git a/docs/cli/README.md b/docs/cli/README.md index f4d786229..13f6fefc5 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -31,3 +31,5 @@ and depends on generated CLI documentation. The root [coding guidelines](../contributing/coding-guidelines.md) and [quality checks](../contributing/quality-checks.md) apply alongside the CLI's own rules. + +- [Local daemon and browser viewer](local-control-plane.md) — scoped telemetry sessions and viewer development. diff --git a/docs/cli/local-control-plane.md b/docs/cli/local-control-plane.md new file mode 100644 index 000000000..8f68d2458 --- /dev/null +++ b/docs/cli/local-control-plane.md @@ -0,0 +1,115 @@ +# Local daemon and browser viewer + +`local run --session ` and `local serve --session ` capture into an +isolated session owned by a background, loopback-only daemon. The foreground +command retains its session when it exits. Closed sessions remain queryable for +three hours after the last activity; each retains at most 500 envelopes. All +telemetry disappears when the daemon stops. + +```sh +sentry local run --session web -- npm run dev +sentry local session list --json +sentry local event list web --json +sentry local event view web --json +sentry local envelope list web --json +sentry local session reset web +sentry local session close web +sentry local daemon status +sentry local daemon stop +``` + +`local run --verify` uses an ephemeral receiver and cannot be combined with +`--session` or `--open`. Invalid combinations are rejected before a receiver or +application is started. Omit `--verify` to capture into a retained daemon session. + +`local session create --json` returns distinct `ingestUrl` and `streamUrl` +capabilities for SDK ingestion and viewer reads. The ingest capability lives in +the URL path so SDK transports that omit query parameters still deliver events. +Treat those URLs as credentials. +Use `--clientId ` to make session creation idempotent for retries. Names must +be unique for name-based queries; ambiguous names return HTTP 409 with guidance +to use the session ID. +Session lookup commands accept names quoted or as separate words. For `event view` and +`envelope view`, the final argument is the telemetry ID; preceding arguments +form the session name. Event lookup uses the Sentry event ID from the envelope +header, event/transaction item header, or payload, in that order; envelopes +without an event ID retain their generated envelope ID as the lookup key. + +The daemon stores its discovery token under `SENTRY_CONFIG_DIR` in an owner-only +state file. It binds only to localhost, 127.0.0.1, or ::1 and uses a nonzero port. +A second daemon cannot replace the running daemon for that config directory. +Restart preserves its configured host and port and clears all in-memory data. +It waits for the previous daemon to release its state and ownership; a rejected +stop or shutdown timeout reports an error while preserving discovery. +A temporarily unreachable live daemon keeps its discovery state for retries. +A short ownership guard serializes claiming, stale recovery and release. If a +process dies during that critical section, startup preserves the guard and reports +its path; confirm the owning process has stopped before removing the guard and +retrying. +Startup waits through the listener retry window and preserves active startup locks. +Concurrent foreground and managed starts join the same daemon: session creation +waits for a live foreground owner to publish authenticated discovery, even if a +competing child exits before that owner finishes binding. +Incomplete owner files receive a five-second grace period. Once safely reclaimed, +ownership is claimed under the same guard, including on the final retry. + +When a managed daemon owns the requested port, plain `local run` and `local serve` +require `--session ` or a different `--port`; they cannot use its protected +receiver as a legacy `/stream` endpoint. + +## Envelope retention + +Each session or plain receiver retains 500 envelopes by default. Set a limit from +1 to 50,000 with `--max-envelopes` on `local session create`, `local run`, or +`local serve`, for example: + +```sh +sentry local run --session web --max-envelopes 1000 -- npm run dev +sentry local session retention web --max-envelopes 2000 +sentry local session retention web --json +``` + +Increasing the limit preserves existing envelopes, their IDs, and active streams; +previously evicted envelopes cannot be recovered. Lowering below the retained count +is rejected without changing any data. Clear envelopes first to reduce that limit. +Changing capacity does not reset the session's three-hour lifecycle retention. +An explicit limit also configures an existing plain receiver; ordinary attachment +preserves its current setting. Older receivers that cannot apply the flag report an +error. Retrying session creation with the same client ID preserves its original settings. + +The viewer follows the receiver's envelope limit and retains every decoded item +inside each retained envelope. Its Retention limit control can update a locally +served receiver or scoped session at runtime without replacing the connection. +Hosted viewers display the setting but cannot change it. CLI changes also update +connected viewers. + +## Browser viewer + +The independently served viewer lives in `apps/local`. Run +`pnpm --filter local dev` to serve it on localhost:5173. With that viewer running, +`local run --session web --open -- npm run dev` opens its scoped stream. Production +CLI builds use ; publishing the migrated app requires +that hosting project to use Toolkit and `apps/local` as its root directory. + +A loopback-served viewer can reset and close its scoped session. The hosted viewer +can read the stream and clear its own displayed feed; it cannot mutate retained +receiver data. Remote HTTPS streams stay read-only. + +Search and view navigation remain available for retained telemetry after a +receiver disconnects or a session closes, including raw-only envelopes. A trace +search with no matches explains how to change or clear the filter. Closing an +empty session returns to the receiver picker. Waterfall failure styling uses +each span's own level, HTTP status, and SDK span status; a failed transaction does not mark healthy child +spans as failures. The AI workspace matches AI namespaces (`ai`, `gen_ai`, +`llm`), including dotted origins, rather than substrings of ordinary operations. + +## Verification + +`pnpm --filter sentry test:e2e` builds the npm bundle and runs the CLI process +suite. `test/e2e/local.test.ts` covers concurrent startup, session isolation, +capability checks, retained queries, scoped browser controls, wrapped applications, +real Sentry Node SDK ingestion, restart, and singleton ownership. The Local workflow repeats these tests against +npm and native binaries on Linux, macOS, and Windows. + +`pnpm --filter local test` includes viewer tests with a real receiver and SSE +connection, as well as scoped reset/close integration coverage. diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 4a98046b5..53ef88550 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -743,7 +743,21 @@ Print configuration and verify authentication Sentry for local development +- `sentry local daemon start` — Start the Local control-plane daemon +- `sentry local daemon status` — Show Local control-plane status +- `sentry local daemon stop` — Stop the Local control-plane daemon +- `sentry local daemon restart` — Restart the Local control-plane daemon +- `sentry local envelope list ` — List retained envelopes for a Local session +- `sentry local envelope view ` — View a raw Local envelope +- `sentry local event list ` — List decoded events for a Local session +- `sentry local event view ` — View a decoded Local event - `sentry local serve` — Start the local dev server and tail events +- `sentry local session create ` — Create a Local telemetry session +- `sentry local session retention ` — View or change session envelope retention +- `sentry local session list` — List Local telemetry sessions +- `sentry local session view ` — View a Local telemetry session +- `sentry local session close ` — Close a Local telemetry session +- `sentry local session reset ` — Clear a Local telemetry session - `sentry local run ` — Run a command with the local dev server enabled → Full flags and examples: `references/local.md` diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md index df92efaab..83a431d62 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md @@ -11,6 +11,43 @@ requires: Sentry for local development +### `sentry local daemon start` + +Start the Local control-plane daemon + +**Flags:** +- `--foreground - Run in the foreground` +- `--host - Loopback host - (default: "localhost")` +- `--port - Control-plane port - (default: "8969")` + +### `sentry local daemon status` + +Show Local control-plane status + +### `sentry local daemon stop` + +Stop the Local control-plane daemon + +### `sentry local daemon restart` + +Restart the Local control-plane daemon + +### `sentry local envelope list ` + +List retained envelopes for a Local session + +### `sentry local envelope view ` + +View a raw Local envelope + +### `sentry local event list ` + +List decoded events for a Local session + +### `sentry local event view ` + +View a decoded Local event + ### `sentry local serve` Start the local dev server and tail events @@ -23,6 +60,41 @@ Start the local dev server and tail events - `-F, --format - Output format: human (default) or json (NDJSON on stdout) - (default: "human")` - `-a, --attributes - Show a grouped attribute table (user vs SDK) under each transaction` - `--open - Open Sentry Local UI in the browser` +- `--max-envelopes - Maximum envelopes retained by this receiver or session` +- `--session - Create a daemon-owned telemetry session with this label` + +### `sentry local session create ` + +Create a Local telemetry session + +**Flags:** +- `--clientId - Idempotency key for retried agent session creation` +- `--host - Loopback host - (default: "localhost")` +- `--port - Control-plane port - (default: "8969")` +- `--max-envelopes - Maximum envelopes retained by this receiver or session - (default: "500")` + +### `sentry local session retention ` + +View or change session envelope retention + +**Flags:** +- `--max-envelopes - Maximum envelopes retained by this receiver or session` + +### `sentry local session list` + +List Local telemetry sessions + +### `sentry local session view ` + +View a Local telemetry session + +### `sentry local session close ` + +Close a Local telemetry session + +### `sentry local session reset ` + +Clear a Local telemetry session ### `sentry local run ` @@ -37,6 +109,8 @@ Run a command with the local dev server enabled - `-F, --format - Output format: human (default) or json (NDJSON on stdout) - (default: "human")` - `-a, --attributes - Include selected event attributes in output` - `--open - Open Sentry Local UI in the browser` +- `--max-envelopes - Maximum envelopes retained by this receiver or session` +- `--session - Create a daemon-owned telemetry session with this label` **Examples:** @@ -84,6 +158,17 @@ curl -X POST http://127.0.0.1:3030/api/agent/run \ -H 'content-type: application/json' \ -d '{"prompt":"Where is the rate limit configured?"}' curl -i http://127.0.0.1:3030/api/broken + +sentry local run --session web -- npm run dev +sentry local serve --session worker +sentry local session list --json +sentry local event list web --json +sentry local event view web --json +sentry local envelope list web --json +sentry local session reset web +sentry local session close web +sentry local daemon status +sentry local daemon stop ``` All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags. diff --git a/packages/cli/src/commands/local/control-plane.ts b/packages/cli/src/commands/local/control-plane.ts new file mode 100644 index 000000000..0c6a58c78 --- /dev/null +++ b/packages/cli/src/commands/local/control-plane.ts @@ -0,0 +1,534 @@ +/** + * HTTP control surface for the singleton Sentry Local daemon. + * + * The daemon owns all session metadata and telemetry buffers. CLI processes use + * its bearer-protected API while SDKs receive only scoped ingestion URLs. + */ + +import { Buffer } from "node:buffer"; +import { pushToSpotlightBuffer } from "@spotlightjs/spotlight/sdk"; +import { type Context, Hono } from "hono"; +import { cors } from "hono/cors"; +import { HTTPException } from "hono/http-exception"; +import { streamSSE } from "hono/streaming"; +import { SENTRY_CONTENT_TYPE } from "../../lib/formatters/local.js"; +import { formatLocalServerUrl } from "./ui.js"; +import { buildSSEHandler, isHostedUiOrigin, MAX_BODY_BYTES } from "./server.js"; +import { logger } from "../../lib/logger.js"; +import { + createLocalSessionStore, + AmbiguousLocalSessionError, + type LocalSessionStore, +} from "./session-store.js"; + +import { LOCAL_RETENTION_EVENT } from "./retention-limits.js"; +import { + RetentionLimitError, + validateMaxEnvelopes, +} from "./retention-buffer.js"; + +export const CONTROL_PLANE_PREFIX = "/_local/v1"; +const LOOPBACK_ORIGIN_RE = + /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/; +const SESSION_UI_ACTION_RE = + /^\/_local\/v1\/sessions\/[^/]+\/ui\/(reset|close|retention)$/; +const SESSION_INGEST_RE = /^\/_local\/v1\/sessions\/[^/]+\/ingest\/[^/]+$/; +const SESSION_STREAM_RE = /^\/_local\/v1\/sessions\/[^/]+\/stream$/; + +/** Authenticated HTTP application and its in-memory session registry. */ +export type LocalControlPlane = { + readonly app: Hono; + readonly sessionStore: LocalSessionStore; +}; + +/** Listener identity, control capability, and optional lifecycle hooks. */ +export type CreateLocalControlPlaneOptions = { + readonly controlToken: string; + readonly host?: string; + readonly onStop?: () => void; + readonly port?: number; + readonly sessionStore?: LocalSessionStore; +}; + +function hasControlCapability(request: Request, controlToken: string): boolean { + return request.headers.get("authorization") === `Bearer ${controlToken}`; +} + +function isSessionUiAction(path: string): boolean { + return SESSION_UI_ACTION_RE.test(path); +} + +function isLoopbackBrowserRequest(request: Request): boolean { + const origin = request.headers.get("origin"); + return origin === null || LOOPBACK_ORIGIN_RE.test(origin); +} + +/** Only nonempty string IDs from event-bearing envelope fields are lookup keys. */ +function eventIdFrom(value: unknown): string | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return undefined; + } + const id = (value as Record).event_id; + return typeof id === "string" && id.length > 0 ? id : undefined; +} + +function eventSummary(parsed: { + envelope: [Record, unknown[]]; +}): { envelopeId: string; eventId: string; itemTypes: string[] } { + const [header, items] = parsed.envelope; + const itemEventId = items.flatMap((item) => { + if ( + !Array.isArray(item) || + typeof item[0] !== "object" || + item[0] === null + ) { + return []; + } + const type = (item[0] as Record).type; + if (type !== "event" && type !== "transaction") { + return []; + } + const id = eventIdFrom(item[0]) ?? eventIdFrom(item[1]); + return id ? [id] : []; + })[0]; + return { + envelopeId: String(header.__spotlight_envelope_id ?? ""), + eventId: + eventIdFrom(header) ?? + itemEventId ?? + String(header.__spotlight_envelope_id ?? ""), + itemTypes: items.map((item) => + Array.isArray(item) && typeof item[0] === "object" && item[0] + ? String((item[0] as { type?: unknown }).type ?? "unknown") + : "unknown", + ), + }; +} + +/** Create the in-process HTTP application hosted by the detached Local daemon. */ +export function createLocalControlPlane({ + controlToken, + host = "localhost", + onStop, + port = 8969, + sessionStore = createLocalSessionStore(), +}: CreateLocalControlPlaneOptions): LocalControlPlane { + const app = new Hono(); + const baseUrl = formatLocalServerUrl(host, port); + const sessionExists = (reference: string): boolean => { + sessionStore.pruneExpired(); + try { + sessionStore.resolve(reference); + return true; + } catch (error) { + if (error instanceof AmbiguousLocalSessionError) { + throw new HTTPException(409, { + res: Response.json({ error: error.message }, { status: 409 }), + }); + } + logger.debug("Local session was not found", error); + return false; + } + }; + + app.get("/health", (c) => { + c.header("X-Sentry-Local-Mode", "daemon"); + return c.text("OK"); + }); + + app.use(`${CONTROL_PLANE_PREFIX}/*`, async (c, next) => { + if ( + SESSION_STREAM_RE.test(c.req.path) || + ((c.req.method === "POST" || c.req.method === "OPTIONS") && + SESSION_INGEST_RE.test(c.req.path)) || + isSessionUiAction(c.req.path) + ) { + await next(); + return; + } + if (!hasControlCapability(c.req.raw, controlToken)) { + return c.body(null, 401); + } + await next(); + }); + const loopbackCors = cors({ + allowHeaders: [ + "Content-Type", + "Content-Encoding", + "User-Agent", + "Last-Event-ID", + ], + allowMethods: ["GET", "POST", "OPTIONS"], + origin: (origin) => (LOOPBACK_ORIGIN_RE.test(origin) ? origin : null), + }); + app.use( + `${CONTROL_PLANE_PREFIX}/sessions/:id/ingest/:cap`, + async (c, next) => { + if (!isLoopbackBrowserRequest(c.req.raw)) { + return c.body(null, 403); + } + return loopbackCors(c, next); + }, + ); + const hostedCors = cors({ + allowHeaders: ["Last-Event-ID"], + allowMethods: ["GET", "OPTIONS"], + origin: (origin) => (isHostedUiOrigin(origin) ? origin : null), + }); + app.use(`${CONTROL_PLANE_PREFIX}/sessions/:id/stream`, async (c, next) => { + const origin = c.req.header("origin") ?? ""; + const isWrite = + c.req.method === "POST" || + (c.req.method === "OPTIONS" && + c.req.header("access-control-request-method") !== "GET"); + if (isWrite && !isLoopbackBrowserRequest(c.req.raw)) { + return c.body(null, 403); + } + if (!isHostedUiOrigin(origin)) { + return loopbackCors(c, next); + } + if (c.req.method === "OPTIONS") { + if (c.req.header("access-control-request-method") !== "GET") { + return c.body(null, 403); + } + c.header("Access-Control-Allow-Origin", origin); + c.header("Access-Control-Allow-Methods", "GET, OPTIONS"); + c.header("Access-Control-Allow-Headers", "Last-Event-ID"); + if (c.req.header("access-control-request-private-network") === "true") { + c.header("Access-Control-Allow-Private-Network", "true"); + } + c.header( + "Vary", + "Origin, Access-Control-Request-Headers, Access-Control-Request-Private-Network", + ); + return c.body(null, 204); + } + if (c.req.method !== "GET") { + return c.body(null, 403); + } + return hostedCors(c, next); + }); + app.use( + `${CONTROL_PLANE_PREFIX}/sessions/:id/ui/*`, + cors({ + allowMethods: ["POST", "OPTIONS"], + allowHeaders: ["Content-Type"], + origin: (origin) => (LOOPBACK_ORIGIN_RE.test(origin) ? origin : null), + }), + ); + + app.get(`${CONTROL_PLANE_PREFIX}/sessions`, (c) => { + sessionStore.pruneExpired(); + return c.json({ sessions: sessionStore.list() }); + }); + + app.get(`${CONTROL_PLANE_PREFIX}/sessions/:id`, (c) => { + if (!sessionExists(c.req.param("id"))) { + return c.body(null, 404); + } + const session = sessionStore.resolve(c.req.param("id")); + const capabilities = sessionStore.getCapabilities(session.id); + const path = `${CONTROL_PLANE_PREFIX}/sessions/${session.id}`; + return c.json({ + ...session, + ingestUrl: `${baseUrl}${path}/ingest/${capabilities.ingest}`, + streamUrl: `${baseUrl}${path}/stream?cap=${capabilities.stream}`, + }); + }); + + app.post(`${CONTROL_PLANE_PREFIX}/sessions`, async (c) => { + const input = await c.req.json().catch((error: unknown) => { + logger.debug("Invalid Local session request body", error); + return; + }); + if ( + typeof input !== "object" || + input === null || + Array.isArray(input) || + typeof (input as { label?: unknown }).label !== "string" || + typeof (input as { cwd?: unknown }).cwd !== "string" || + ("clientId" in input && + typeof (input as { clientId?: unknown }).clientId !== "string") + ) { + return c.json({ error: "label and cwd are required." }, 400); + } + const { clientId, label, cwd, maxEnvelopes } = input as { + clientId?: string; + label: string; + cwd: string; + maxEnvelopes?: number; + }; + if (maxEnvelopes !== undefined) { + try { + validateMaxEnvelopes(maxEnvelopes); + } catch (error) { + if (error instanceof RetentionLimitError) + return c.json({ error: error.message }, error.status); + throw error; + } + } + const session = sessionStore.create({ clientId, label, cwd, maxEnvelopes }); + const capabilities = sessionStore.getCapabilities(session.id); + const path = `${CONTROL_PLANE_PREFIX}/sessions/${session.id}`; + return c.json( + { + ...session, + ingestUrl: `${baseUrl}${path}/ingest/${capabilities.ingest}`, + streamUrl: `${baseUrl}${path}/stream?cap=${capabilities.stream}`, + }, + 201, + ); + }); + + // Node SDK Spotlight transports send pathname only, so the ingest capability + // must live in the path. Keep query-based ingestion for existing raw clients. + const ingest = async (c: Context) => { + const reference = c.req.param("id") ?? ""; + if (!sessionExists(reference)) { + return c.body(null, 404); + } + const capabilities = sessionStore.getCapabilities(reference); + if ((c.req.param("cap") ?? c.req.query("cap")) !== capabilities.ingest) { + return c.body(null, 401); + } + + if (Number(c.req.header("content-length") ?? 0) > MAX_BODY_BYTES) { + return c.body(null, 413); + } + const arrayBuffer = await c.req.arrayBuffer(); + if (arrayBuffer.byteLength > MAX_BODY_BYTES) { + return c.body(null, 413); + } + const body = Buffer.from(arrayBuffer); + const rawEncoding = c.req.header("content-encoding"); + const encoding = + rawEncoding === "gzip" || + rawEncoding === "deflate" || + rawEncoding === "br" + ? rawEncoding + : undefined; + const browserBeacon = + c.req.query("sentry_client")?.startsWith("sentry.javascript.browser") && + c.req.header("origin"); + pushToSpotlightBuffer({ + spotlightBuffer: sessionStore.getBuffer(reference), + body, + contentType: browserBeacon + ? SENTRY_CONTENT_TYPE + : (c.req.header("content-type") ?? ""), + encoding, + userAgent: c.req.header("user-agent"), + }); + sessionStore.recordActivity(reference); + return c.body(null, 204); + }; + app.post(`${CONTROL_PLANE_PREFIX}/sessions/:id/ingest/:cap`, ingest); + app.post(`${CONTROL_PLANE_PREFIX}/sessions/:id/stream`, ingest); + + app.get(`${CONTROL_PLANE_PREFIX}/sessions/:id/stream`, (c) => { + if (!sessionExists(c.req.param("id"))) { + return c.body(null, 404); + } + const capabilities = sessionStore.getCapabilities(c.req.param("id")); + const buffer = sessionStore.getBuffer(c.req.param("id")); + if (c.req.query("cap") !== capabilities.stream) { + return c.body(null, 401); + } + return streamSSE(c, async (stream) => { + const retention = sessionStore.getRetention(c.req.param("id")); + const sendSettings = ( + settings: ReturnType, + ) => { + void stream + .writeSSE({ + event: LOCAL_RETENTION_EVENT, + data: JSON.stringify(settings), + }) + .catch((error) => + logger.debug("Local retention stream closed", error), + ); + }; + const unsubscribeSettings = + c.req.query("retention") === "1" + ? retention.subscribeSettings(sendSettings) + : () => {}; + if (c.req.query("retention") === "1") + sendSettings(retention.getSettings()); + const readerId = buffer.subscribe( + buildSSEHandler(stream, c.req.query("base64") !== undefined), + c.req.header("Last-Event-ID"), + ); + await new Promise((resolve) => { + stream.onAbort(() => { + buffer.unsubscribe(readerId); + unsubscribeSettings(); + resolve(); + }); + }); + }); + }); + + const updateRetention = async (c: Context) => { + const input: unknown = await c.req.json().catch(() => undefined); + const value = + typeof input === "object" && input !== null && "maxEnvelopes" in input + ? input.maxEnvelopes + : undefined; + try { + validateMaxEnvelopes(value); + sessionStore.setMaxEnvelopes(c.req.param("id") ?? "", value); + return c.json( + sessionStore.getRetention(c.req.param("id") ?? "").getSettings(), + ); + } catch (error) { + if (error instanceof RetentionLimitError) + return c.json({ error: error.message }, error.status); + throw error; + } + }; + app.get(`${CONTROL_PLANE_PREFIX}/sessions/:id/retention`, (c) => + sessionExists(c.req.param("id")) + ? c.json(sessionStore.getRetention(c.req.param("id")).getSettings()) + : c.body(null, 404), + ); + app.post(`${CONTROL_PLANE_PREFIX}/sessions/:id/retention`, (c) => + sessionExists(c.req.param("id")) ? updateRetention(c) : c.body(null, 404), + ); + + app.post(`${CONTROL_PLANE_PREFIX}/sessions/:id/reset`, (c) => { + if (!sessionExists(c.req.param("id"))) { + return c.body(null, 404); + } + sessionStore.getBuffer(c.req.param("id")).clear(); + return c.body(null, 204); + }); + + app.post(`${CONTROL_PLANE_PREFIX}/sessions/:id/close`, (c) => { + if (!sessionExists(c.req.param("id"))) { + return c.body(null, 404); + } + return c.json(sessionStore.close(c.req.param("id"))); + }); + + app.post(`${CONTROL_PLANE_PREFIX}/sessions/:id/ui/:action`, async (c) => { + if (!(isSessionUiAction(c.req.path) && sessionExists(c.req.param("id")))) { + return c.body(null, 404); + } + if (!isLoopbackBrowserRequest(c.req.raw)) { + return c.body(null, 403); + } + if ( + c.req.query("cap") !== + sessionStore.getCapabilities(c.req.param("id")).stream + ) { + return c.body(null, 401); + } + if (c.req.param("action") === "retention") return updateRetention(c); + if (c.req.param("action") === "reset") { + sessionStore.getBuffer(c.req.param("id")).clear(); + return c.body(null, 204); + } + return c.json(sessionStore.close(c.req.param("id"))); + }); + + app.get(`${CONTROL_PLANE_PREFIX}/sessions/:id/envelopes`, (c) => { + if (!sessionExists(c.req.param("id"))) { + return c.body(null, 404); + } + const envelopes = sessionStore + .getBuffer(c.req.param("id")) + .read({ all: true }) + .map((container) => { + const parsed = container.getParsedEnvelope(); + const header = (parsed?.envelope[0] ?? {}) as { + __spotlight_envelope_id?: unknown; + }; + return { + id: String(header.__spotlight_envelope_id ?? ""), + contentType: container.getContentType(), + itemTypes: + parsed?.envelope[1] + .map((item) => + Array.isArray(item) && typeof item[0] === "object" && item[0] + ? String((item[0] as { type?: unknown }).type ?? "unknown") + : "unknown", + ) + .filter(Boolean) ?? [], + }; + }) + .reverse(); + return c.json({ envelopes }); + }); + + app.get(`${CONTROL_PLANE_PREFIX}/sessions/:id/events`, (c) => { + if (!sessionExists(c.req.param("id"))) { + return c.body(null, 404); + } + const events = sessionStore + .getBuffer(c.req.param("id")) + .read({ all: true }) + .flatMap((container) => { + const parsed = container.getParsedEnvelope(); + if (!parsed) { + return []; + } + return [ + { + ...eventSummary(parsed), + }, + ]; + }) + .reverse(); + return c.json({ events }); + }); + + app.get(`${CONTROL_PLANE_PREFIX}/sessions/:id/events/:eventId`, (c) => { + if (!sessionExists(c.req.param("id"))) { + return c.body(null, 404); + } + const container = sessionStore + .getBuffer(c.req.param("id")) + .read({ all: true }) + .find((candidate) => { + const parsed = candidate.getParsedEnvelope(); + return ( + parsed && eventSummary(parsed).eventId === c.req.param("eventId") + ); + }); + const parsed = container?.getParsedEnvelope(); + if (!parsed) { + return c.body(null, 404); + } + return c.json({ + ...eventSummary(parsed), + envelope: parsed.envelope, + }); + }); + + app.get(`${CONTROL_PLANE_PREFIX}/sessions/:id/envelopes/:envelopeId`, (c) => { + if (!sessionExists(c.req.param("id"))) { + return c.body(null, 404); + } + const container = sessionStore + .getBuffer(c.req.param("id")) + .read({ envelopeId: c.req.param("envelopeId") })[0]; + if (!container) { + return c.body(null, 404); + } + return c.json({ + contentType: container.getContentType(), + data: Buffer.from(container.getData()).toString("base64"), + id: c.req.param("envelopeId"), + }); + }); + + app.post(`${CONTROL_PLANE_PREFIX}/daemon/stop`, (c) => { + if (!onStop) { + return c.body(null, 409); + } + setTimeout(onStop, 0); + return c.body(null, 202); + }); + + return { app, sessionStore }; +} diff --git a/packages/cli/src/commands/local/daemon.ts b/packages/cli/src/commands/local/daemon.ts new file mode 100644 index 000000000..d30237e54 --- /dev/null +++ b/packages/cli/src/commands/local/daemon.ts @@ -0,0 +1,639 @@ +/** + * Durable discovery state for the singleton Local control-plane daemon. + * + * Telemetry never lives here: this file only lets independently invoked CLI + * processes authenticate to the currently running loopback daemon. + */ + +import { type ChildProcess, spawn } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { + chmod, + mkdir, + open, + readFile, + rename, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import type { Server } from "node:http"; +import { join } from "node:path"; +import { isSea } from "../../lib/react-native/wrap-call.js"; +import { setTimeout as sleep } from "node:timers/promises"; +import { getConfigDir } from "../../lib/db/index.js"; +import { formatLocalServerUrl } from "./ui.js"; +import { logger } from "../../lib/logger.js"; +import { + CONTROL_PLANE_PREFIX, + createLocalControlPlane, +} from "./control-plane.js"; +import { + DEFAULT_PORT, + isLoopbackHost, + PORT_RETRY_TIMEOUT_MS, + tryListen, +} from "./server.js"; + +export const LOCAL_DAEMON_STATE_FILENAME = "local-control-plane.json"; +export const LOCAL_DAEMON_PROTOCOL_VERSION = 1; +export const LOCAL_DAEMON_IDLE_GRACE_MS = 30_000; +/** Listener retries plus time for CLI bootstrap and discovery persistence. */ +const LOCAL_DAEMON_START_TIMEOUT_MS = PORT_RETRY_TIMEOUT_MS + 5_000; + +/** Private discovery metadata for the authenticated loopback daemon. */ +export type LocalDaemonState = { + /** Loopback listener host. */ + readonly host: string; + /** Owning process ID, used for lifecycle checks. */ + readonly pid: number; + /** Bound TCP port, from 1 through 65535. */ + readonly port: number; + /** Secret control capability; never include in diagnostics. */ + readonly token: string; + /** Discovery protocol version. */ + readonly version: number; +}; + +/** Session identifiers and explicitly returned SDK/viewer capabilities. */ +export type LocalDaemonSession = { + readonly cwd: string; + readonly id: string; + readonly ingestUrl: string; + readonly label: string; + readonly streamUrl: string; +}; + +function statePath(directory: string): string { + return join(directory, LOCAL_DAEMON_STATE_FILENAME); +} + +function startupLockPath(directory: string): string { + return join(directory, "local-control-plane.lock"); +} + +function shutdownServer(server: Server): Promise { + return new Promise((resolve) => { + server.close(() => resolve()); + server.closeAllConnections?.(); + }); +} + +/** Read daemon discovery metadata, treating absent or malformed files as stale. */ +export async function readLocalDaemonState( + directory: string, +): Promise { + try { + const parsed = JSON.parse( + await readFile(statePath(directory), "utf8"), + ) as unknown; + if ( + typeof parsed !== "object" || + parsed === null || + typeof (parsed as LocalDaemonState).host !== "string" || + !isLoopbackHost((parsed as LocalDaemonState).host) || + !Number.isInteger((parsed as LocalDaemonState).pid) || + (parsed as LocalDaemonState).pid <= 0 || + !Number.isInteger((parsed as LocalDaemonState).port) || + (parsed as LocalDaemonState).port <= 0 || + (parsed as LocalDaemonState).port > 65535 || + typeof (parsed as LocalDaemonState).token !== "string" || + !(parsed as LocalDaemonState).token || + typeof (parsed as LocalDaemonState).version !== "number" + ) { + return; + } + return parsed as LocalDaemonState; + } catch (error) { + logger.debug("Could not read Local daemon state", error); + return; + } +} + +/** Atomically persist the current daemon's discovery state with owner-only access. */ +export async function writeLocalDaemonState( + directory: string, + state: LocalDaemonState, +): Promise { + await mkdir(directory, { recursive: true, mode: 0o700 }); + const path = statePath(directory); + const temporaryPath = `${path}.${process.pid}.tmp`; + await writeFile(temporaryPath, `${JSON.stringify(state)}\n`, { + encoding: "utf8", + mode: 0o600, + }); + await chmod(temporaryPath, 0o600).catch((error: unknown) => { + logger.debug("Could not protect Local daemon state", error); + }); + await rename(temporaryPath, path); + await chmod(path, 0o600).catch((error: unknown) => { + logger.debug("Could not protect Local daemon state", error); + }); +} + +/** Remove discovery state after a clean shutdown or stale-daemon recovery. */ +export async function removeLocalDaemonState(directory: string): Promise { + await rm(statePath(directory), { force: true }); +} + +function daemonBaseUrl(state: LocalDaemonState): string { + return formatLocalServerUrl(state.host, state.port); +} + +async function daemonResponds(state: LocalDaemonState): Promise { + try { + const response = await requestLocalDaemon(state, "/sessions", { + signal: AbortSignal.timeout(500), + }); + if (!response.ok) return false; + const data = (await response.json()) as { sessions?: unknown }; + return Array.isArray(data.sessions); + } catch (error) { + logger.debug("Local daemon identity check failed", error); + return false; + } +} + +/** Arguments for re-executing this CLI as a native binary or Node entrypoint. */ +export function buildLocalDaemonArgs(host: string, port: number): string[] { + const prefix: string[] = []; + if (!isSea()) { + const entrypoint = process.argv[1]; + if (!entrypoint) { + throw new Error( + "Could not determine the Sentry CLI entrypoint for Local daemon startup.", + ); + } + prefix.push(...process.execArgv, entrypoint); + } + return [ + ...prefix, + "local", + "daemon", + "start", + "--foreground", + "--host", + host, + "--port", + String(port), + ]; +} + +/** Serialize ownership creation, stale recovery and release across processes. */ +async function withDaemonOwnershipGuard( + directory: string, + action: () => Promise, +): Promise { + const path = join(directory, "local-control-plane.owner.guard"); + for (let attempt = 0; attempt < 40; attempt += 1) { + let guard: Awaited>; + try { + guard = await open(path, "wx", 0o600); + } catch (error) { + if ( + !(error instanceof Error && "code" in error && error.code === "EEXIST") + ) + throw error; + const pid = await readOwnerPid(path); + if (Number.isInteger(pid) && pid > 0 && !isProcessRunning(pid)) { + // Automatic unlink could erase a replacement guard. An interrupted + // short critical section requires explicit cleanup, not stale recovery. + throw new Error( + `Local daemon ownership arbitration was interrupted. After confirming its process has stopped, remove ${path} and retry.`, + ); + } + await sleep(125); + continue; + } + try { + await guard.writeFile(String(process.pid), "utf8"); + return await action(); + } finally { + await guard.close(); + await rm(path, { force: true }); + } + } + throw new Error( + "Timed out waiting for Local daemon ownership arbitration; active ownership has been preserved.", + ); +} + +/** Hold exclusive daemon ownership until shutdown; recover locks from dead processes. */ +export async function claimDaemonOwnership( + directory: string, +): Promise<() => Promise> { + const path = join(directory, "local-control-plane.owner"); + const tryClaim = async () => { + let lock: Awaited>; + try { + lock = await open(path, "wx", 0o600); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "EEXIST") + return undefined; + throw error; + } + try { + await lock.writeFile(String(process.pid), "utf8"); + } finally { + await lock.close(); + } + return () => + withDaemonOwnershipGuard(directory, async () => { + if ((await readOwnerPid(path)) === process.pid) + await rm(path, { force: true }); + }); + }; + for (let attempt = 0; attempt < 40; attempt += 1) { + const release = await withDaemonOwnershipGuard(directory, async () => { + const claimed = await tryClaim(); + if (claimed) return claimed; + try { + const ownerText = await readFile(path, "utf8"); + const owner = Number(ownerText); + const snapshot = await stat(path); + const validPid = Number.isInteger(owner) && owner > 0; + if (validPid && isProcessRunning(owner)) { + throw new Error( + "The Sentry Local control-plane daemon is already running.", + ); + } + if (validPid || Date.now() - snapshot.mtimeMs > 5000) { + const currentText = await readFile(path, "utf8"); + const current = await stat(path); + if ( + currentText === ownerText && + current.ino === snapshot.ino && + current.dev === snapshot.dev && + current.mtimeMs === snapshot.mtimeMs && + (!validPid || !isProcessRunning(owner)) + ) { + await rm(path, { force: true }); + // Claim while the guard is still held, even on the final recovery attempt. + return tryClaim(); + } + } + } catch (error) { + if ( + !( + error instanceof Error && + "code" in error && + error.code === "ENOENT" + ) + ) + throw error; + return tryClaim(); + } + return undefined; + }); + if (release) return release; + await sleep(125); + } + throw new Error("Timed out waiting for Sentry Local daemon ownership."); +} + +/** A failed probe is not proof that a live process has relinquished ownership. */ +function isProcessRunning(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ESRCH") + return false; + logger.debug( + "Could not verify Local daemon process; preserving its discovery", + error, + ); + return true; + } +} + +/** Read a lock's owning PID without interpreting an absent file as ownership. */ +async function readOwnerPid(path: string): Promise { + try { + return Number(await readFile(path, "utf8")); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") + return 0; + logger.debug("Could not read Local startup ownership", error); + throw error; + } +} + +/** Keep discovery reads non-destructive; daemon startup replaces stale metadata. */ +async function discoverLocalDaemon( + waitForResponse = false, +): Promise { + const directory = getConfigDir(); + const state = await readLocalDaemonState(directory); + if (!state) return; + const responds = + state.version === LOCAL_DAEMON_PROTOCOL_VERSION && + (await daemonResponds(state)); + // A startup or shutdown can replace/remove metadata while the probe is in + // flight. Rediscover changed state and never unlink based on a stale snapshot. + const current = await readLocalDaemonState(directory); + if (!current) return; + if ( + current.pid !== state.pid || + current.token !== state.token || + current.host !== state.host || + current.port !== state.port || + current.version !== state.version + ) { + return discoverLocalDaemon(waitForResponse); + } + if (responds) return state; + if (isProcessRunning(state.pid)) { + if (waitForResponse && state.version === LOCAL_DAEMON_PROTOCOL_VERSION) + return; + throw new Error( + "The Local daemon is still running but did not respond with a compatible identity. Retry the command; its discovery state has been preserved.", + ); + } +} + +/** Return authenticated discovery, preserving unreachable live owners for retries. */ +export async function getRunningLocalDaemon(): Promise< + LocalDaemonState | undefined +> { + return discoverLocalDaemon(); +} + +/** Wait for the stopped daemon to relinquish state and ownership, even while its PID lives. */ +export async function waitForLocalDaemonShutdown( + state: LocalDaemonState, +): Promise { + const directory = getConfigDir(); + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + const current = await readLocalDaemonState(directory); + const owner = await readOwnerPid( + join(directory, "local-control-plane.owner"), + ); + const ownsState = + current?.pid === state.pid && current.token === state.token; + if ((!ownsState && owner !== state.pid) || !isProcessRunning(state.pid)) + return; + await sleep(125); + } + throw new Error( + "Timed out waiting for the Local daemon to stop. Its discovery state has been preserved.", + ); +} + +/** Wait through the listener's complete retry window without erasing live state. */ +async function waitForLocalDaemon( + deadline: number, + startupError?: () => Error | undefined, +): Promise { + while (Date.now() < deadline) { + await sleep(125); + const state = await discoverLocalDaemon(true); + if (state) return state; + const error = startupError?.(); + if (error) { + const owner = await readOwnerPid( + join(getConfigDir(), "local-control-plane.owner"), + ); + // A foreground owner can win after our spawn decision but still be binding. + if (!isProcessRunning(owner)) throw error; + } + } +} + +/** Start the daemon child and wait until its authenticated discovery state is available. */ +export async function ensureLocalDaemon({ + host = "localhost", + port = DEFAULT_PORT, +}: { host?: string; port?: number } = {}): Promise { + if (!isLoopbackHost(host)) + throw new Error("The Local control-plane host must be loopback-only."); + if (port === 0) + throw new Error("The Local control-plane port must be non-zero."); + const running = await getRunningLocalDaemon(); + if (running) return running; + const directory = getConfigDir(); + await mkdir(directory, { recursive: true, mode: 0o700 }); + let lock: Awaited>; + try { + lock = await open(startupLockPath(directory), "wx", 0o600); + } catch (error) { + if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) + throw error; + const state = await waitForLocalDaemon( + Date.now() + LOCAL_DAEMON_START_TIMEOUT_MS, + ); + if (state) return state; + const starter = await readOwnerPid(startupLockPath(directory)); + const owner = await readOwnerPid( + join(directory, "local-control-plane.owner"), + ); + if (isProcessRunning(starter) || isProcessRunning(owner)) { + throw new Error( + "Local daemon startup is still in progress. Retry the command; the active startup lock has been preserved.", + ); + } + await rm(startupLockPath(directory), { force: true }); + return ensureLocalDaemon({ host, port }); + } + let child: ChildProcess | undefined; + let ready = false; + try { + await lock.writeFile(String(process.pid), "utf8"); + const runningAfterLock = await getRunningLocalDaemon(); + if (runningAfterLock) { + ready = true; + return runningAfterLock; + } + let startupError: Error | undefined; + const owner = await readOwnerPid( + join(directory, "local-control-plane.owner"), + ); + if (!isProcessRunning(owner)) { + child = spawn(process.execPath, buildLocalDaemonArgs(host, port), { + detached: true, + env: process.env, + stdio: "ignore", + }); + child.once("error", (error) => { + startupError = error; + }); + child.once("exit", (code, signal) => { + startupError = new Error( + `Local daemon exited before startup (status ${code ?? signal}). Check that the requested port is available.`, + ); + }); + child.unref(); + } + + const state = await waitForLocalDaemon( + Date.now() + LOCAL_DAEMON_START_TIMEOUT_MS, + () => startupError, + ); + if (state) { + ready = true; + return state; + } + throw new Error("Timed out while starting the Sentry Local control plane."); + } finally { + if (!ready) child?.kill("SIGTERM"); + await lock.close(); + if ((await readOwnerPid(startupLockPath(directory))) === process.pid) { + await rm(startupLockPath(directory), { force: true }); + } + } +} + +/** Run the daemon until an explicit control-plane stop request or process signal arrives. */ +export async function runLocalDaemonForeground({ + host = "localhost", + port = DEFAULT_PORT, +}: { + host?: string; + port?: number; +} = {}): Promise { + if (!isLoopbackHost(host)) { + throw new Error("The Local control-plane host must be loopback-only."); + } + if (port === 0) { + throw new Error("The Local control-plane port must be non-zero."); + } + const directory = getConfigDir(); + await mkdir(directory, { recursive: true, mode: 0o700 }); + const releaseOwnership = await claimDaemonOwnership(directory); + let listening: Awaited> | undefined; + let ownedState: LocalDaemonState | undefined; + try { + const token = randomBytes(32).toString("base64url"); + let stop: (() => void) | undefined; + const stopRequested = new Promise((resolve) => { + stop = resolve; + }); + const controlPlane = createLocalControlPlane({ + controlToken: token, + host, + port, + onStop: () => stop?.(), + }); + listening = await tryListen(controlPlane.app, port, host); + const state: LocalDaemonState = { + host, + pid: process.pid, + port: listening.port, + token, + version: LOCAL_DAEMON_PROTOCOL_VERSION, + }; + ownedState = state; + await writeLocalDaemonState(directory, state); + + let idleTimer: ReturnType | undefined; + const pruneSessions = () => { + controlPlane.sessionStore.pruneExpired(); + if (controlPlane.sessionStore.list().length > 0) { + if (idleTimer !== undefined) { + clearTimeout(idleTimer); + idleTimer = undefined; + } + return; + } + idleTimer ??= setTimeout(() => { + if (controlPlane.sessionStore.list().length === 0) { + stop?.(); + } + }, LOCAL_DAEMON_IDLE_GRACE_MS); + }; + const pruneTimer = setInterval(pruneSessions, 60_000); + pruneSessions(); + + const onSignal = () => stop?.(); + process.once("SIGINT", onSignal); + process.once("SIGTERM", onSignal); + try { + await stopRequested; + } finally { + clearInterval(pruneTimer); + if (idleTimer !== undefined) { + clearTimeout(idleTimer); + } + process.removeListener("SIGINT", onSignal); + process.removeListener("SIGTERM", onSignal); + } + } finally { + try { + if (listening) await shutdownServer(listening.server); + const current = await readLocalDaemonState(directory); + if ( + current && + ownedState && + current.pid === ownedState.pid && + current.token === ownedState.token + ) { + await removeLocalDaemonState(directory); + } + } finally { + await releaseOwnership(); + } + } +} + +/** Call an authenticated Local daemon endpoint. */ +export function requestLocalDaemon( + state: LocalDaemonState, + path: string, + init: RequestInit = {}, +): Promise { + const headers = new Headers(init.headers); + headers.set("Authorization", `Bearer ${state.token}`); + return fetch(`${daemonBaseUrl(state)}${CONTROL_PLANE_PREFIX}${path}`, { + ...init, + headers, + }); +} + +/** Create a session and return its separate ingestion and read capabilities. */ +export async function createLocalDaemonSession({ + clientId, + cwd, + host, + label, + port, + maxEnvelopes, +}: { + readonly clientId?: string; + readonly maxEnvelopes?: number; + readonly cwd: string; + readonly host?: string; + readonly label: string; + readonly port?: number; +}): Promise { + const state = await ensureLocalDaemon({ host, port }); + const response = await requestLocalDaemon(state, "/sessions", { + body: JSON.stringify({ clientId, cwd, label, maxEnvelopes }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + if (!response.ok) { + throw new Error( + `Could not create Local session (HTTP ${response.status}).`, + ); + } + return (await response.json()) as LocalDaemonSession; +} + +/** Release a foreground session lease while retaining telemetry for queries. */ +export async function retainLocalDaemonSession( + sessionId: string, +): Promise { + const state = await getRunningLocalDaemon(); + if (!state) { + return; + } + await requestLocalDaemon( + state, + `/sessions/${encodeURIComponent(sessionId)}/close`, + { + method: "POST", + }, + ); +} diff --git a/packages/cli/src/commands/local/index.ts b/packages/cli/src/commands/local/index.ts index 3255cc597..21f55c310 100644 --- a/packages/cli/src/commands/local/index.ts +++ b/packages/cli/src/commands/local/index.ts @@ -1,10 +1,20 @@ import { buildRouteMap } from "../../lib/route-map.js"; +import { + daemonRoute, + envelopeRoute, + eventRoute, + sessionRoute, +} from "./management.js"; import { runCommand } from "./run.js"; import { serverCommand } from "./server.js"; export const localRoute = buildRouteMap({ routes: { + daemon: daemonRoute, + envelope: envelopeRoute, + event: eventRoute, serve: serverCommand, + session: sessionRoute, run: runCommand, }, defaultCommand: "serve", @@ -15,6 +25,10 @@ export const localRoute = buildRouteMap({ "from your dev stack.\n\n" + "Commands:\n" + " serve Start the server and tail events (default)\n" + - " run Run a command with SENTRY_SPOTLIGHT auto-injected", + " run Run a command with SENTRY_SPOTLIGHT auto-injected\n" + + " daemon Manage the shared telemetry daemon\n" + + " session Create, inspect, configure, reset, and close sessions\n" + + " event Query decoded session events\n" + + " envelope Query raw session envelopes", }, }); diff --git a/packages/cli/src/commands/local/management.ts b/packages/cli/src/commands/local/management.ts new file mode 100644 index 000000000..a60b1bcd0 --- /dev/null +++ b/packages/cli/src/commands/local/management.ts @@ -0,0 +1,397 @@ +/** Local control-plane management and agent query commands. */ + +import { basename } from "node:path"; +import type { SentryContext } from "../../context.js"; +import { buildCommand } from "../../lib/command.js"; +import { CliError, ValidationError } from "../../lib/errors.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; +import { buildRouteMap } from "../../lib/route-map.js"; +import { + ensureLocalDaemon, + type LocalDaemonState, + getRunningLocalDaemon, + requestLocalDaemon, + runLocalDaemonForeground, + waitForLocalDaemonShutdown, +} from "./daemon.js"; +import { parsePort } from "./server.js"; +import { logger } from "../../lib/logger.js"; +import { redactCredentialText } from "../../lib/credential-redaction.js"; + +import { + MAX_ENVELOPES_FLAG, + OPTIONAL_MAX_ENVELOPES_FLAG, +} from "./retention-buffer.js"; + +type DaemonFlags = { + readonly foreground: boolean; + readonly host: string; + readonly port: number; +}; +type SessionFlags = { + readonly "max-envelopes": number; + readonly clientId?: string; + readonly host: string; + readonly port: number; +}; +type ControlResult = Record; + +function publicDaemonState({ + host, + pid, + port, + version, +}: LocalDaemonState): ControlResult { + return { host, pid, port, version }; +} + +function formatHuman(data: ControlResult): string { + return JSON.stringify(data, null, 2); +} + +const CONTROL_COMMAND_DEFAULTS = { + auth: false, + output: { human: formatHuman }, +} as const; + +const DAEMON_CONNECTION_FLAGS = { + host: { + kind: "parsed", + parse: String, + brief: "Loopback host", + default: "localhost", + }, + port: { + kind: "parsed", + parse: parsePort, + brief: "Control-plane port", + default: "8969", + }, +} as const; + +const SESSION_REFERENCE_POSITIONAL = { + kind: "array", + parameter: { + brief: "Session ID or unique name", + parse: String, + placeholder: "session", + }, +} as const; + +async function controlJson( + path: string, + init: RequestInit = {}, + options: { host?: string; port?: number } = {}, +): Promise { + const state = await ensureLocalDaemon(options); + const response = await requestLocalDaemon(state, path, init); + if (!response.ok) { + const body: unknown = await response.json().catch((error: unknown) => { + logger.debug("Could not read Local control-plane error details", error); + return undefined; + }); + const detail = + typeof body === "object" && + body !== null && + "error" in body && + typeof body.error === "string" + ? ` ${redactCredentialText(body.error)}` + : ""; + throw new CliError( + `Sentry Local control plane request failed with HTTP ${response.status}.${detail}`, + 1, + ); + } + if (response.status === 204 || response.status === 202) { + return { ok: true }; + } + return (await response.json()) as ControlResult; +} + +function singleReference(parts: string[]): string { + const reference = parts.join(" ").trim(); + if (!reference) { + throw new ValidationError("Provide a Local session or envelope ID.", "id"); + } + return reference; +} + +const daemonStartCommand = buildCommand({ + docs: { brief: "Start the Local control-plane daemon" }, + ...CONTROL_COMMAND_DEFAULTS, + parameters: { + flags: { + foreground: { + kind: "boolean", + brief: "Run in the foreground", + default: false, + }, + ...DAEMON_CONNECTION_FLAGS, + }, + }, + async *func(flags: DaemonFlags) { + if (flags.foreground) { + await runLocalDaemonForeground(flags); + return; + } + yield new CommandOutput(publicDaemonState(await ensureLocalDaemon(flags))); + }, +}); + +const daemonStatusCommand = buildCommand({ + docs: { brief: "Show Local control-plane status" }, + ...CONTROL_COMMAND_DEFAULTS, + parameters: { flags: {} }, + async *func() { + const daemon = await getRunningLocalDaemon(); + yield new CommandOutput({ + daemon: daemon ? publicDaemonState(daemon) : null, + }); + }, +}); + +const daemonStopCommand = buildCommand({ + docs: { brief: "Stop the Local control-plane daemon" }, + ...CONTROL_COMMAND_DEFAULTS, + parameters: { flags: {} }, + async *func() { + const state = await getRunningLocalDaemon(); + if (!state) { + yield new CommandOutput({ stopped: false, reason: "not running" }); + return; + } + const response = await requestLocalDaemon(state, "/daemon/stop", { + method: "POST", + }); + yield new CommandOutput({ stopped: response.ok }); + }, +}); + +const daemonRestartCommand = buildCommand({ + docs: { brief: "Restart the Local control-plane daemon" }, + ...CONTROL_COMMAND_DEFAULTS, + parameters: { flags: {} }, + async *func() { + const state = await getRunningLocalDaemon(); + if (state) { + const response = await requestLocalDaemon(state, "/daemon/stop", { + method: "POST", + signal: AbortSignal.timeout(5_000), + }).catch((error: unknown) => { + if ( + error instanceof Error && + (error.name === "TimeoutError" || error.name === "AbortError") + ) { + throw new CliError( + "Timed out requesting Local daemon shutdown; its discovery state has been preserved.", + 1, + ); + } + throw error; + }); + if (!response.ok) { + throw new CliError( + `Could not stop the Local daemon for restart (HTTP ${response.status}).`, + 1, + ); + } + await waitForLocalDaemonShutdown(state); + } + yield new CommandOutput( + publicDaemonState( + await ensureLocalDaemon( + state ? { host: state.host, port: state.port } : {}, + ), + ), + ); + }, +}); + +const sessionCreateCommand = buildCommand({ + docs: { brief: "Create a Local telemetry session" }, + ...CONTROL_COMMAND_DEFAULTS, + parameters: { + flags: { + clientId: { + kind: "parsed", + parse: String, + brief: "Idempotency key for retried agent session creation", + optional: true, + }, + ...DAEMON_CONNECTION_FLAGS, + "max-envelopes": MAX_ENVELOPES_FLAG, + }, + positional: { + kind: "array", + parameter: { + brief: "Optional session label", + parse: String, + placeholder: "name", + }, + }, + }, + async *func(this: SentryContext, flags: SessionFlags, ...parts: string[]) { + const label = parts.join(" ").trim() || basename(this.cwd); + yield new CommandOutput( + await controlJson( + "/sessions", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + clientId: flags.clientId, + maxEnvelopes: flags["max-envelopes"], + label, + cwd: this.cwd, + }), + }, + flags, + ), + ); + }, +}); + +const sessionListCommand = buildCommand({ + docs: { brief: "List Local telemetry sessions" }, + ...CONTROL_COMMAND_DEFAULTS, + parameters: { flags: {} }, + async *func() { + yield new CommandOutput(await controlJson("/sessions")); + }, +}); + +/** Commands that target one session share argument parsing and URL construction. */ +function buildSessionCommand( + brief: string, + suffix = "", + method: "GET" | "POST" = "GET", +): ReturnType { + return buildCommand({ + ...CONTROL_COMMAND_DEFAULTS, + docs: { brief }, + parameters: { flags: {}, positional: SESSION_REFERENCE_POSITIONAL }, + async *func(_flags, ...parts: string[]) { + yield new CommandOutput( + await controlJson( + `/sessions/${encodeURIComponent(singleReference(parts))}${suffix}`, + { method }, + ), + ); + }, + }); +} + +/** Event and envelope lookup both use the final argument as the telemetry ID. */ +function buildTelemetryViewCommand( + kind: "event" | "envelope", + brief: string, +): ReturnType { + return buildCommand({ + ...CONTROL_COMMAND_DEFAULTS, + docs: { brief }, + parameters: { + flags: {}, + positional: { + kind: "array", + parameter: { + brief: `Session and ${kind} ID`, + parse: String, + placeholder: `session ${kind}`, + }, + }, + }, + async *func(_flags, ...parts: string[]) { + const session = parts.slice(0, -1).join(" ").trim(); + const id = parts.at(-1); + if (!(session && id)) { + throw new ValidationError(`Provide a session and ${kind} ID.`, kind); + } + yield new CommandOutput( + await controlJson( + `/sessions/${encodeURIComponent(session)}/${kind}s/${encodeURIComponent(id)}`, + ), + ); + }, + }); +} + +const sessionRetentionCommand = buildCommand({ + ...CONTROL_COMMAND_DEFAULTS, + docs: { brief: "View or change session envelope retention" }, + parameters: { + flags: { "max-envelopes": OPTIONAL_MAX_ENVELOPES_FLAG }, + positional: SESSION_REFERENCE_POSITIONAL, + }, + async *func( + flags: { readonly "max-envelopes"?: number }, + ...parts: string[] + ) { + const path = `/sessions/${encodeURIComponent(singleReference(parts))}/retention`; + const value = flags["max-envelopes"]; + yield new CommandOutput( + await controlJson( + path, + value === undefined + ? {} + : { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ maxEnvelopes: value }), + }, + ), + ); + }, +}); + +export const daemonRoute = buildRouteMap({ + routes: { + start: daemonStartCommand, + status: daemonStatusCommand, + stop: daemonStopCommand, + restart: daemonRestartCommand, + }, + defaultCommand: "status", + docs: { brief: "Manage the Local control plane" }, +}); +export const sessionRoute = buildRouteMap({ + routes: { + create: sessionCreateCommand, + retention: sessionRetentionCommand, + list: sessionListCommand, + view: buildSessionCommand("View a Local telemetry session"), + close: buildSessionCommand( + "Close a Local telemetry session", + "/close", + "POST", + ), + reset: buildSessionCommand( + "Clear a Local telemetry session", + "/reset", + "POST", + ), + }, + defaultCommand: "list", + docs: { brief: "Manage Local telemetry sessions" }, +}); +export const envelopeRoute = buildRouteMap({ + routes: { + list: buildSessionCommand( + "List retained envelopes for a Local session", + "/envelopes", + ), + view: buildTelemetryViewCommand("envelope", "View a raw Local envelope"), + }, + defaultCommand: "list", + docs: { brief: "Query Local envelopes" }, +}); +export const eventRoute = buildRouteMap({ + routes: { + list: buildSessionCommand( + "List decoded events for a Local session", + "/events", + ), + view: buildTelemetryViewCommand("event", "View a decoded Local event"), + }, + defaultCommand: "list", + docs: { brief: "Query Local decoded events" }, +}); diff --git a/packages/cli/src/commands/local/retention-buffer.ts b/packages/cli/src/commands/local/retention-buffer.ts new file mode 100644 index 000000000..9f4f13fe9 --- /dev/null +++ b/packages/cli/src/commands/local/retention-buffer.ts @@ -0,0 +1,148 @@ +/** Resize through the Spotlight SDK's public API while keeping a stable buffer facade. */ +import { createSpotlightBuffer } from "@spotlightjs/spotlight/sdk"; +import { ValidationError } from "../../lib/errors.js"; +import { + DEFAULT_MAX_ENVELOPES, + isValidMaxEnvelopes, + MAX_ENVELOPES, +} from "./retention-limits.js"; + +type SpotlightBuffer = ReturnType; +type Subscriber = { + callback: Parameters[0]; + id: string; +}; +export type RetentionSettings = { + maxEnvelopes: number; + retainedEnvelopes: number; +}; +export type LocalRetentionBuffer = { + buffer: SpotlightBuffer; + getSettings: () => RetentionSettings; + resize: (maxEnvelopes: number) => void; + subscribeSettings: ( + listener: (settings: RetentionSettings) => void, + ) => () => void; +}; +export class RetentionLimitError extends Error { + constructor( + message: string, + readonly status: 400 | 409 = 400, + ) { + super(message); + this.name = "RetentionLimitError"; + } +} +export function validateMaxEnvelopes(value: unknown): asserts value is number { + if (!isValidMaxEnvelopes(value)) + throw new RetentionLimitError( + `Retention limit must be an integer between 1 and ${MAX_ENVELOPES}.`, + ); +} +const limitFlag = { + kind: "parsed", + brief: "Maximum envelopes retained by this receiver or session", + parse(value: string): number { + const parsed = /^\d+$/.test(value) ? Number(value) : NaN; + if (!isValidMaxEnvelopes(parsed)) + throw new ValidationError( + `Retention limit must be an integer between 1 and ${MAX_ENVELOPES}.`, + "max-envelopes", + ); + return parsed; + }, +} as const; +export const MAX_ENVELOPES_FLAG = { + ...limitFlag, + default: String(DEFAULT_MAX_ENVELOPES), +} as const; +export const OPTIONAL_MAX_ENVELOPES_FLAG = { + ...limitFlag, + optional: true, +} as const; + +export function createLocalRetentionBuffer( + maxEnvelopes = DEFAULT_MAX_ENVELOPES, +): LocalRetentionBuffer { + validateMaxEnvelopes(maxEnvelopes); + let delegate = createSpotlightBuffer(maxEnvelopes); + const buffer = createSpotlightBuffer(1); + const subscribers = new Map(); + const arrivalTimes = new WeakMap(); + const listeners = new Set<(settings: RetentionSettings) => void>(); + const getSettings = (): RetentionSettings => ({ + maxEnvelopes, + retainedEnvelopes: delegate.read({ all: true }).length, + }); + buffer.put = (item) => { + arrivalTimes.set(item, Date.now()); + delegate.put(item); + }; + buffer.read = (filters = { timeWindow: 60 }) => { + if ("timeWindow" in filters && filters.timeWindow !== undefined) { + const { timeWindow, ...rest } = filters; + return delegate + .read({ ...rest, all: true }) + .filter( + (item) => + (arrivalTimes.get(item) ?? 0) > Date.now() - timeWindow * 1000, + ); + } + return delegate.read(filters); + }; + buffer.subscribe = (callback, lastEventId) => { + const id = delegate.subscribe(callback, lastEventId); + subscribers.set(id, { callback, id }); + return id; + }; + buffer.unsubscribe = (id) => { + const subscriber = subscribers.get(id); + if (subscriber) delegate.unsubscribe(subscriber.id); + subscribers.delete(id); + }; + buffer.stream = (id) => { + const subscriber = subscribers.get(id); + if (subscriber) delegate.stream(subscriber.id); + }; + buffer.clear = () => delegate.clear(); + buffer.reset = () => delegate.reset(); + return { + buffer, + getSettings, + resize(nextLimit) { + validateMaxEnvelopes(nextLimit); + const retained = delegate.read({ all: true }); + if (nextLimit < retained.length) + throw new RetentionLimitError( + `Cannot lower the limit below ${retained.length} retained envelopes. Clear envelopes first.`, + 409, + ); + if (nextLimit === maxEnvelopes) return; + // Flush queued deliveries before moving readers, then advance each new reader + // past the copied history so an active stream neither misses nor repeats it. + for (const subscriber of subscribers.values()) + delegate.stream(subscriber.id); + const next = createSpotlightBuffer(nextLimit); + for (const item of retained.reverse()) next.put(item); + const previous = delegate; + delegate = next; + for (const subscriber of subscribers.values()) { + previous.unsubscribe(subscriber.id); + let replaying = true; + subscriber.id = next.subscribe((item) => { + if (!replaying) subscriber.callback(item); + }); + next.stream(subscriber.id); + replaying = false; + } + maxEnvelopes = nextLimit; + for (const listener of listeners) listener(getSettings()); + }, + subscribeSettings(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; +} diff --git a/packages/cli/src/commands/local/retention-limits.ts b/packages/cli/src/commands/local/retention-limits.ts new file mode 100644 index 000000000..e704b6a7b --- /dev/null +++ b/packages/cli/src/commands/local/retention-limits.ts @@ -0,0 +1,13 @@ +/** Shared envelope-count limits for the Local receiver and browser viewer. */ +export const DEFAULT_MAX_ENVELOPES = 500; +export const MAX_ENVELOPES = 50_000; +export const LOCAL_RETENTION_EVENT = "sentry.local.retention"; + +export function isValidMaxEnvelopes(value: unknown): value is number { + return ( + typeof value === "number" && + Number.isSafeInteger(value) && + value >= 1 && + value <= MAX_ENVELOPES + ); +} diff --git a/packages/cli/src/commands/local/run.ts b/packages/cli/src/commands/local/run.ts index 6f37ca41f..c5c59e868 100644 --- a/packages/cli/src/commands/local/run.ts +++ b/packages/cli/src/commands/local/run.ts @@ -19,8 +19,7 @@ import { spawn, } from "node:child_process"; import type { Server } from "node:http"; -import { resolve } from "node:path"; -import { createSpotlightBuffer } from "@spotlightjs/spotlight/sdk"; +import { basename, resolve } from "node:path"; import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; import { detectDevCommand } from "../../lib/dev-script.js"; @@ -34,12 +33,17 @@ import { } from "../../lib/formatters/local.js"; import { logger, printJsonLine, printLine } from "../../lib/logger.js"; import { injectWranglerSpotlightBinding } from "../../lib/wrangler.js"; +import { + createLocalDaemonSession, + retainLocalDaemonSession, +} from "./daemon.js"; import { buildApp, consumeSSE, DEFAULT_PORT, isLoopbackHost, isServerRunning, + configureExistingRetention, parseFilter, parseFormat, parsePort, @@ -51,6 +55,11 @@ import { validateOpenHost, } from "./ui.js"; +import { + createLocalRetentionBuffer, + OPTIONAL_MAX_ENVELOPES_FLAG, +} from "./retention-buffer.js"; + type RunFlags = { readonly port: number; readonly host: string; @@ -60,6 +69,8 @@ type RunFlags = { readonly format: FormatValue; readonly attributes: boolean; readonly open: boolean; + readonly session?: string; + readonly "max-envelopes"?: number; }; /** Buffer size for the auto-started background server. */ @@ -135,6 +146,7 @@ type EventTailOptions = { activeFilters: ReadonlySet; useJson: boolean; showAttributes: boolean; + maxEnvelopes?: number; }; /** Keep stdout exclusively for NDJSON when an agent requests JSON output. */ @@ -160,15 +172,23 @@ function forwardChildOutput(child: ChildProcess, useJson: boolean): void { * reached the other server, but nothing was ever printed here. Attaching as * an SSE consumer is what `sentry local serve` does in the same situation. */ -function attachToExistingServer( - url: string, - activeFilters: ReadonlySet, - useJson: boolean, - showAttributes: boolean, -): EventTail { +function attachToExistingServer({ + activeFilters, + showAttributes, + streamUrl, + url, + useJson, +}: { + activeFilters: ReadonlySet; + showAttributes: boolean; + streamUrl?: string; + url: string; + useJson: boolean; +}): EventTail { const ac = new AbortController(); const tail = consumeSSE({ url, + streamUrl, activeFilters, signal: ac.signal, useJson, @@ -200,9 +220,11 @@ async function startBackgroundServer({ activeFilters, useJson, showAttributes, + maxEnvelopes, }: EventTailOptions): Promise { - const buffer = createSpotlightBuffer(BUFFER_SIZE); - const app = buildApp(buffer, { uiActions: isLoopbackHost(host) }); + const retention = createLocalRetentionBuffer(maxEnvelopes); + const buffer = retention.buffer; + const app = buildApp(buffer, { uiActions: isLoopbackHost(host), retention }); const { server, port: boundPort } = await tryListen(app, port, host); const url = formatLocalServerUrl(host, boundPort); @@ -248,17 +270,25 @@ async function openEventTail({ activeFilters, useJson, showAttributes, + maxEnvelopes, }: EventTailOptions): Promise { const url = formatLocalServerUrl(host, port); if (await isServerRunning(url)) { + await configureExistingRetention(url, maxEnvelopes); logger.info(`Connected to existing server at ${bold(url)}`); - return attachToExistingServer(url, activeFilters, useJson, showAttributes); + return attachToExistingServer({ + activeFilters, + showAttributes, + url, + useJson, + }); } logger.info("No server detected, starting one in the background..."); try { const bg = await startBackgroundServer({ + maxEnvelopes, port, host, activeFilters, @@ -271,9 +301,15 @@ async function openEventTail({ if (!(err instanceof ValidationError && err.field === "port")) { throw err; } + await configureExistingRetention(url, maxEnvelopes); // Something grabbed the port between the probe and the bind. logger.warn(`${err.message}; attaching to it instead`); - return attachToExistingServer(url, activeFilters, useJson, showAttributes); + return attachToExistingServer({ + activeFilters, + showAttributes, + url, + useJson, + }); } } @@ -420,6 +456,13 @@ export const runCommand = buildCommand({ brief: "Open Sentry Local UI in the browser", default: false, }, + "max-envelopes": OPTIONAL_MAX_ENVELOPES_FLAG, + session: { + kind: "parsed", + parse: String, + brief: "Create a daemon-owned telemetry session with this label", + optional: true, + }, }, aliases: { p: "port", @@ -435,6 +478,12 @@ export const runCommand = buildCommand({ if (flags.open && flags.verify) { throw new ValidationError("--open cannot be used with --verify.", "open"); } + if (flags.session !== undefined && flags.verify) { + throw new ValidationError( + "--session cannot be used with --verify.", + "session", + ); + } validateOpenHost(flags.open, flags.host); const stripped = rawArgs[0] === "--" ? rawArgs.slice(1) : rawArgs; @@ -446,21 +495,45 @@ export const runCommand = buildCommand({ } let url = formatLocalServerUrl(flags.host, flags.port); + let daemonSessionId: string | undefined; + let daemonIngestUrl: string | undefined; + let daemonStreamUrl: string | undefined; const useJson = flags.format === "json"; const activeFilters = new Set(flags.filter); - const tail = await openEventTail({ - port: flags.port, - host: flags.host, - activeFilters, - useJson, - showAttributes: flags.attributes, - }); + const tail = flags.session + ? await (async () => { + const session = await createLocalDaemonSession({ + maxEnvelopes: flags["max-envelopes"], + cwd: this.cwd, + host: flags.host, + label: flags.session || basename(this.cwd), + port: flags.port, + }); + daemonSessionId = session.id; + daemonIngestUrl = session.ingestUrl; + daemonStreamUrl = session.streamUrl; + return attachToExistingServer({ + activeFilters, + showAttributes: flags.attributes, + streamUrl: session.streamUrl, + url: new URL(session.streamUrl).origin, + useJson, + }); + })() + : await openEventTail({ + maxEnvelopes: flags["max-envelopes"], + port: flags.port, + host: flags.host, + activeFilters, + useJson, + showAttributes: flags.attributes, + }); url = tail.url; - await openLocalUiIfRequested(flags.open, url); + await openLocalUiIfRequested(flags.open, daemonStreamUrl ?? url); - const spotlightUrl = `${url}/stream`; + const spotlightUrl = daemonIngestUrl ?? `${url}/stream`; const wrangler = await injectWranglerSpotlightBinding( args, spotlightUrl, @@ -470,7 +543,11 @@ export const runCommand = buildCommand({ logger.info("Injected SENTRY_SPOTLIGHT as a Wrangler Worker binding"); } logger.info(`Starting: ${bold(wrangler.args.join(" "))}`); - logger.info(`SENTRY_SPOTLIGHT=${spotlightUrl}`); + logger.info( + daemonSessionId + ? `SENTRY_SPOTLIGHT configured for session ${daemonSessionId}. Use sentry local session view ${daemonSessionId} --json for its ingest URL.` + : `SENTRY_SPOTLIGHT=${spotlightUrl}`, + ); const childEnv = buildChildEnv(spotlightUrl, commandSource, this.cwd); @@ -485,6 +562,9 @@ export const runCommand = buildCommand({ forwardChildOutput(child, useJson); } catch (err) { await tail.cleanup(); + if (daemonSessionId) { + await retainLocalDaemonSession(daemonSessionId); + } throw new CliError( `Failed to start "${wrangler.args[0]}": ${err instanceof Error ? err.message : String(err)}`, EXIT.GENERAL, @@ -529,6 +609,9 @@ export const runCommand = buildCommand({ process.removeListener("SIGINT", onSigint); process.removeListener("SIGTERM", onSigterm); await tail.cleanup(); + if (daemonSessionId) { + await retainLocalDaemonSession(daemonSessionId); + } } if (exitCode !== 0) { @@ -589,8 +672,12 @@ async function* runWithVerify( cwd: string, commandSource: string, ): AsyncGenerator { - const buffer = createSpotlightBuffer(BUFFER_SIZE); - const app = buildApp(buffer, { uiActions: isLoopbackHost(flags.host) }); + const retention = createLocalRetentionBuffer(flags["max-envelopes"]); + const buffer = retention.buffer; + const app = buildApp(buffer, { + uiActions: isLoopbackHost(flags.host), + retention, + }); const { server, port: boundPort } = await tryListen( app, flags.port, diff --git a/packages/cli/src/commands/local/server.ts b/packages/cli/src/commands/local/server.ts index 32f9bd7dd..0b975b222 100644 --- a/packages/cli/src/commands/local/server.ts +++ b/packages/cli/src/commands/local/server.ts @@ -12,6 +12,7 @@ */ import type { Server } from "node:http"; +import { basename } from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; import { serve } from "@hono/node-server"; import { @@ -24,6 +25,7 @@ import { streamSSE } from "hono/streaming"; import type { SentryContext } from "../../context.js"; import { buildCommand, numberParser } from "../../lib/command.js"; import { ValidationError } from "../../lib/errors.js"; +import { redactCredentialText } from "../../lib/credential-redaction.js"; import { bold } from "../../lib/formatters/colors.js"; import type { FilterValue, FormatValue } from "../../lib/formatters/local.js"; import { @@ -43,6 +45,15 @@ import { validateOpenHost, } from "./ui.js"; +import { + createLocalRetentionBuffer, + type LocalRetentionBuffer, + OPTIONAL_MAX_ENVELOPES_FLAG, + RetentionLimitError, + validateMaxEnvelopes, +} from "./retention-buffer.js"; +import { LOCAL_RETENTION_EVENT } from "./retention-limits.js"; + /** Default port for the local dev server. */ export const DEFAULT_PORT = 8969; @@ -65,7 +76,7 @@ const BUFFER_SIZE = 500; const CR_RE = /\r$/; /** Maximum ingest body size (10 MB). Rejects oversized payloads early. */ -const MAX_BODY_BYTES = 10 * 1024 * 1024; +export const MAX_BODY_BYTES = 10 * 1024 * 1024; /** * Parse and validate a `--format` value. @@ -105,6 +116,8 @@ type LocalFlags = { readonly format: FormatValue; readonly attributes: boolean; readonly open: boolean; + readonly session?: string; + readonly "max-envelopes"?: number; }; /** @@ -142,9 +155,11 @@ export function isLoopbackHost(host: string): boolean { type LocalReceiverOptions = { /** Enables stateful UI routes only when the receiver itself is loopback-bound. */ uiActions?: boolean; + retention?: LocalRetentionBuffer; }; -function isHostedUiOrigin(origin: string | undefined): origin is string { +/** Whether an origin is the production or preview Local viewer. */ +export function isHostedUiOrigin(origin: string | undefined): origin is string { if (!origin) { return false; } @@ -197,7 +212,7 @@ function isHostedUiStreamRequest(request: { * suffix (via `?base64` on `/stream`). Spotlight UI clients use the suffix * to pick their decode path, so it must be echoed back or they drop events. */ -function buildSSEHandler( +export function buildSSEHandler( stream: { writeSSE: (event: { id?: string; @@ -244,7 +259,7 @@ function buildSSEHandler( export function buildApp( spotlightBuffer: ReturnType, - { uiActions = false }: LocalReceiverOptions = {}, + { uiActions = false, retention }: LocalReceiverOptions = {}, ): Hono { const app = new Hono(); @@ -319,10 +334,35 @@ export function buildApp( ? c.json({ actions: { clear: true, envelope: true }, retention: "session", + ...retention?.getSettings(), }) : c.body(null, 403), ); + app.post("/retention", async (c) => { + const origin = c.req.header("origin"); + if ( + !uiActions || + !retention || + (origin && !LOCALHOST_ORIGIN_RE.test(origin)) + ) + return c.body(null, 403); + const input: unknown = await c.req.json().catch(() => undefined); + const value = + typeof input === "object" && input !== null && "maxEnvelopes" in input + ? input.maxEnvelopes + : undefined; + try { + validateMaxEnvelopes(value); + retention.resize(value); + return c.json(retention.getSettings()); + } catch (error) { + if (error instanceof RetentionLimitError) + return c.json({ error: error.message }, error.status); + throw error; + } + }); + app.delete("/clear", (c) => { if (!uiActions) { return c.body(null, 403); @@ -420,11 +460,31 @@ export function buildApp( return streamSSE(c, async (stream) => { const lastEventId = c.req.header("Last-Event-ID"); const onEnvelope = buildSSEHandler(stream, useBase64); + const sendSettings = (settings: { + maxEnvelopes: number; + retainedEnvelopes: number; + }) => { + void stream + .writeSSE({ + event: LOCAL_RETENTION_EVENT, + data: JSON.stringify(settings), + }) + .catch((error) => + logger.debug("Local retention stream closed", error), + ); + }; + const unsubscribeSettings = + retention && c.req.query("retention") === "1" + ? retention.subscribeSettings(sendSettings) + : () => {}; + if (retention && c.req.query("retention") === "1") + sendSettings(retention.getSettings()); const readerId = spotlightBuffer.subscribe(onEnvelope, lastEventId); await new Promise((resolve) => { stream.onAbort(() => { spotlightBuffer.unsubscribe(readerId); + unsubscribeSettings(); resolve(); }); }); @@ -471,6 +531,9 @@ const MAX_PORT_RETRIES = 3; /** Delay between EADDRINUSE retries in milliseconds. */ const PORT_RETRY_DELAY_MS = 5000; +/** Maximum time spent waiting between listen attempts. */ +export const PORT_RETRY_TIMEOUT_MS = MAX_PORT_RETRIES * PORT_RETRY_DELAY_MS; + /** * Try to start the HTTP server, retrying with backoff on EADDRINUSE. * @@ -539,16 +602,9 @@ export function tryListen( * protocol, {@link consumeSSE} reports that when the stream fails to open. */ export async function isServerRunning(url: string): Promise { + let res: Response; try { - const res = await fetch(`${url}/health`, { - signal: AbortSignal.timeout(2000), - }); - if (!res.ok) { - logger.debug( - `Server at ${url} answered /health with HTTP ${res.status}; treating the port as occupied`, - ); - } - return true; + res = await fetch(`${url}/health`, { signal: AbortSignal.timeout(2000) }); } catch (err) { logger.debug( `No existing server at ${url}`, @@ -556,6 +612,63 @@ export async function isServerRunning(url: string): Promise { ); return false; } + if (res.headers.get("X-Sentry-Local-Mode") === "daemon") { + throw new ValidationError( + "A Sentry Local daemon is using this port. Add --session or choose another --port.", + "session", + ); + } + if (!res.ok) { + logger.debug( + `Server at ${url} answered /health with HTTP ${res.status}; treating the port as occupied`, + ); + } + return true; +} + +/** Apply only an explicitly requested limit when attaching to an existing receiver. */ +export async function configureExistingRetention( + url: string, + maxEnvelopes?: number, +): Promise { + if (maxEnvelopes === undefined) return; + validateMaxEnvelopes(maxEnvelopes); + let response: Response; + try { + response = await fetch(`${url}/retention`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ maxEnvelopes }), + signal: AbortSignal.timeout(5000), + }); + } catch { + throw new ValidationError( + "Could not configure retention on the existing receiver. Start a receiver with --max-envelopes on another port.", + "max-envelopes", + ); + } + const body: unknown = await response.json().catch(() => undefined); + if (!response.ok) { + const detail = + typeof body === "object" && + body !== null && + "error" in body && + typeof body.error === "string" + ? body.error + : "The existing receiver does not support retention changes; choose another port."; + throw new ValidationError(redactCredentialText(detail), "max-envelopes"); + } + if ( + typeof body !== "object" || + body === null || + !("maxEnvelopes" in body) || + body.maxEnvelopes !== maxEnvelopes + ) { + throw new ValidationError( + "The existing receiver did not confirm the requested retention limit; choose another port.", + "max-envelopes", + ); + } } /** Mutable state for the SSE line parser. */ @@ -600,6 +713,8 @@ const SSE_MAX_RETRY_MS = 30_000; /** Options for consuming an SSE stream. */ export type ConsumeSSEOptions = { url: string; + /** A full scoped stream URL, when consuming a daemon-owned session. */ + streamUrl?: string; activeFilters: ReadonlySet; signal: AbortSignal; quiet?: boolean; @@ -638,6 +753,7 @@ async function sleepUnlessAborted( export async function consumeSSE(opts: ConsumeSSEOptions): Promise { const { url, + streamUrl, activeFilters, signal, quiet = false, @@ -652,6 +768,7 @@ export async function consumeSSE(opts: ConsumeSSEOptions): Promise { while (!signal.aborted) { const result = await attemptSSEConnection({ url, + streamUrl, activeFilters, signal, quiet, @@ -674,7 +791,9 @@ export async function consumeSSE(opts: ConsumeSSEOptions): Promise { // `/health` answered but `/stream` never opened, so nothing will ever // be printed. Say so — `local run` keeps the child process going, and // without this it just looks like events stopped arriving. - logger.warn(`Could not attach to the event stream at ${url}/stream`); + logger.warn( + `Could not attach to the event stream at ${redactCredentialText(streamUrl ?? `${url}/stream`)}`, + ); return; } // Reset backoff after a successful connection that later dropped, @@ -734,6 +853,7 @@ async function attemptSSEConnection( /** Options for a single SSE connection attempt. */ type ConsumeSSEOnceOptions = { url: string; + streamUrl?: string; activeFilters: ReadonlySet; signal: AbortSignal; quiet: boolean; @@ -766,7 +886,10 @@ async function consumeSSEOnce(opts: ConsumeSSEOnceOptions): Promise { if (lastEventId) { headers["Last-Event-ID"] = lastEventId; } - const res = await fetch(`${url}/stream`, { headers, signal }); + const res = await fetch(opts.streamUrl ?? `${url}/stream`, { + headers, + signal, + }); if (!res.ok) { logger.warn(`SSE stream returned HTTP ${res.status}`); return false; @@ -917,6 +1040,13 @@ export const serverCommand = buildCommand({ brief: "Open Sentry Local UI in the browser", default: false, }, + "max-envelopes": OPTIONAL_MAX_ENVELOPES_FLAG, + session: { + kind: "parsed", + parse: String, + brief: "Create a daemon-owned telemetry session with this label", + optional: true, + }, }, aliases: { p: "port", @@ -934,7 +1064,48 @@ export const serverCommand = buildCommand({ const activeFilters = new Set(flags.filter); const url = formatLocalServerUrl(flags.host, flags.port); + if (flags.session) { + const { createLocalDaemonSession, retainLocalDaemonSession } = + await import("./daemon.js"); + const session = await createLocalDaemonSession({ + maxEnvelopes: flags["max-envelopes"], + cwd: this.cwd, + host: flags.host, + label: flags.session || basename(this.cwd), + port: flags.port, + }); + logger.info("Sentry Local telemetry session"); + logger.info(` Session: ${bold(session.id)} (${session.label})`); + logger.info( + ` Capabilities: sentry local session view ${session.id} --json`, + ); + logger.info("Press Ctrl-C to stop."); + await openLocalUiIfRequested(flags.open, session.streamUrl); + + const controller = new AbortController(); + const stop = () => controller.abort(); + process.once("SIGINT", stop); + process.once("SIGTERM", stop); + try { + await consumeSSE({ + activeFilters, + quiet: flags.quiet, + showAttributes: flags.attributes, + signal: controller.signal, + streamUrl: session.streamUrl, + url: new URL(session.streamUrl).origin, + useJson: flags.format === "json", + }); + } finally { + process.removeListener("SIGINT", stop); + process.removeListener("SIGTERM", stop); + await retainLocalDaemonSession(session.id); + } + return; + } + if (await isServerRunning(url)) { + await configureExistingRetention(url, flags["max-envelopes"]); logger.info(`Connected to existing server at ${bold(url)}`); await openLocalUiIfRequested(flags.open, url); if (activeFilters.size > 0) { @@ -968,7 +1139,10 @@ export const serverCommand = buildCommand({ return; } - const buffer = createSpotlightBuffer(BUFFER_SIZE); + const retention = createLocalRetentionBuffer( + flags["max-envelopes"] ?? BUFFER_SIZE, + ); + const buffer = retention.buffer; const useJson = flags.format === "json"; if (!flags.quiet) { @@ -990,7 +1164,10 @@ export const serverCommand = buildCommand({ }); } - const app = buildApp(buffer, { uiActions: isLoopbackHost(flags.host) }); + const app = buildApp(buffer, { + uiActions: isLoopbackHost(flags.host), + retention, + }); const { server, port: boundPort } = await tryListen( app, diff --git a/packages/cli/src/commands/local/session-store.ts b/packages/cli/src/commands/local/session-store.ts new file mode 100644 index 000000000..2fac52445 --- /dev/null +++ b/packages/cli/src/commands/local/session-store.ts @@ -0,0 +1,232 @@ +/** + * In-memory lifecycle and isolation for Local control-plane sessions. + * + * Session metadata and envelopes intentionally disappear when the daemon exits. + */ + +import { randomBytes } from "node:crypto"; +import { createSpotlightBuffer } from "@spotlightjs/spotlight/sdk"; +import { + createLocalRetentionBuffer, + type LocalRetentionBuffer, + validateMaxEnvelopes, +} from "./retention-buffer.js"; +import { DEFAULT_MAX_ENVELOPES } from "./retention-limits.js"; +import { uuidv7 } from "uuidv7"; + +/** Maximum number of envelopes retained by an individual Local session. */ +export const SESSION_BUFFER_SIZE = DEFAULT_MAX_ENVELOPES; + +/** Retained sessions remain queryable for three hours after closing. */ +export const SESSION_RETENTION_MS = 3 * 60 * 60 * 1000; + +/** Open lease or a closed session retained for later queries. */ +export type LocalSessionState = "open" | "retained"; + +/** Public session metadata, excluding telemetry buffers and secret capabilities. */ +export type LocalSession = { + readonly clientId?: string; + readonly id: string; + readonly label: string; + readonly cwd: string; + readonly createdAt: number; + readonly lastActivityAt: number; + readonly state: LocalSessionState; + readonly expiresAt?: number; + readonly maxEnvelopes: number; +}; + +type StoredSession = LocalSession & { + buffer: ReturnType; + retention: LocalRetentionBuffer; + ingestCapability: string; + streamCapability: string; +}; + +/** Session label, working directory, and optional retry idempotency key. */ +export type CreateLocalSessionInput = { + readonly clientId?: string; + readonly label: string; + readonly cwd: string; + readonly maxEnvelopes?: number; +}; + +/** A label matches multiple sessions; callers must select an explicit ID. */ +export class AmbiguousLocalSessionError extends Error { + constructor(reference: string) { + super( + `Local session name "${reference}" is ambiguous; use its session ID.`, + ); + this.name = "AmbiguousLocalSessionError"; + } +} + +/** In-memory session lifecycle and access to isolated telemetry buffers. */ +export type LocalSessionStore = { + create: (input: CreateLocalSessionInput) => LocalSession; + setMaxEnvelopes: (reference: string, value: number) => LocalSession; + getRetention: (reference: string) => LocalRetentionBuffer; + close: (reference: string) => LocalSession; + getCapabilities: (reference: string) => { + ingest: string; + stream: string; + }; + getBuffer: (reference: string) => ReturnType; + list: () => LocalSession[]; + pruneExpired: () => string[]; + recordActivity: (reference: string) => LocalSession; + resolve: (reference: string) => LocalSession; +}; + +function toPublicSession(session: StoredSession): LocalSession { + const { + buffer: _buffer, + retention: _retention, + ingestCapability: _ingestCapability, + streamCapability: _streamCapability, + ...publicSession + } = session; + return publicSession; +} + +/** Build a session store with injectable time for deterministic lifecycle tests. */ +export function createLocalSessionStore({ + now = Date.now, +}: { + now?: () => number; +} = {}): LocalSessionStore { + const sessions = new Map(); + const clientSessions = new Map(); + + const resolveStored = (reference: string): StoredSession => { + const byId = sessions.get(reference); + if (byId) { + return byId; + } + const matches = [...sessions.values()].filter( + (session) => session.label === reference, + ); + if (matches.length === 0) { + throw new Error(`Local session "${reference}" was not found.`); + } + if (matches.length > 1) { + throw new AmbiguousLocalSessionError(reference); + } + const [match] = matches; + if (!match) { + throw new Error(`Local session "${reference}" was not found.`); + } + return match; + }; + + const update = ( + session: StoredSession, + changes: Partial< + Omit + >, + ): StoredSession => { + const next = { ...session, ...changes }; + sessions.set(next.id, next); + return next; + }; + + return { + create({ clientId, label, cwd, maxEnvelopes = DEFAULT_MAX_ENVELOPES }) { + validateMaxEnvelopes(maxEnvelopes); + const existingId = clientId ? clientSessions.get(clientId) : undefined; + if (existingId) { + return toPublicSession(resolveStored(existingId)); + } + const timestamp = now(); + const retention = createLocalRetentionBuffer(maxEnvelopes); + const session: StoredSession = { + id: uuidv7(), + clientId, + label, + cwd, + createdAt: timestamp, + lastActivityAt: timestamp, + state: "open", + buffer: retention.buffer, + retention, + maxEnvelopes, + ingestCapability: randomBytes(24).toString("base64url"), + streamCapability: randomBytes(24).toString("base64url"), + }; + sessions.set(session.id, session); + if (clientId) { + clientSessions.set(clientId, session.id); + } + return toPublicSession(session); + }, + getRetention(reference) { + return resolveStored(reference).retention; + }, + setMaxEnvelopes(reference, value) { + const session = resolveStored(reference); + session.retention.resize(value); + return toPublicSession(update(session, { maxEnvelopes: value })); + }, + close(reference) { + const timestamp = now(); + const session = resolveStored(reference); + return toPublicSession( + update(session, { + state: "retained", + lastActivityAt: timestamp, + expiresAt: timestamp + SESSION_RETENTION_MS, + }), + ); + }, + getBuffer(reference) { + return resolveStored(reference).buffer; + }, + getCapabilities(reference) { + const session = resolveStored(reference); + return { + ingest: session.ingestCapability, + stream: session.streamCapability, + }; + }, + list() { + return [...sessions.values()] + .map(toPublicSession) + .sort((left, right) => right.lastActivityAt - left.lastActivityAt); + }, + pruneExpired() { + const timestamp = now(); + const expired = [...sessions.values()] + .filter( + (session) => + session.state === "retained" && + session.expiresAt !== undefined && + session.expiresAt <= timestamp, + ) + .map((session) => session.id); + for (const id of expired) { + const session = sessions.get(id); + if (session?.clientId && clientSessions.get(session.clientId) === id) { + clientSessions.delete(session.clientId); + } + sessions.delete(id); + } + return expired; + }, + recordActivity(reference) { + const timestamp = now(); + const session = resolveStored(reference); + return toPublicSession( + update(session, { + lastActivityAt: timestamp, + expiresAt: + session.state === "retained" + ? timestamp + SESSION_RETENTION_MS + : undefined, + }), + ); + }, + resolve(reference) { + return toPublicSession(resolveStored(reference)); + }, + }; +} diff --git a/packages/cli/src/commands/local/ui.ts b/packages/cli/src/commands/local/ui.ts index 80bc7f4a6..12751501f 100644 --- a/packages/cli/src/commands/local/ui.ts +++ b/packages/cli/src/commands/local/ui.ts @@ -51,7 +51,9 @@ export function buildLocalUiUrl(receiverUrl: string): string { const receiver = new URL(receiverUrl); assertLoopbackHostForUi(receiver.hostname); - const streamUrl = new URL("/stream", receiver).toString(); + const streamUrl = receiver.pathname.endsWith("/stream") + ? receiver.toString() + : new URL("/stream", receiver).toString(); const uiUrl = new URL(getLocalUiBaseUrl()); uiUrl.hash = new URLSearchParams({ stream: streamUrl }).toString(); return uiUrl.toString(); @@ -70,6 +72,13 @@ export async function openLocalUi(receiverUrl: string): Promise { logger.info("Opening Sentry Local UI..."); return; } + const streamUrl = new URL(receiverUrl); + if (streamUrl.searchParams.has("cap")) { + logger.warn( + "Could not open browser. Use sentry local session view --json to retrieve its stream URL.", + ); + return; + } logger.warn(`Could not open browser. Visit ${uiUrl}`); } diff --git a/packages/cli/src/lib/credential-redaction.ts b/packages/cli/src/lib/credential-redaction.ts index 1765df0c5..2b35010d6 100644 --- a/packages/cli/src/lib/credential-redaction.ts +++ b/packages/cli/src/lib/credential-redaction.ts @@ -4,6 +4,13 @@ * Successful API responses are not passed through this redactor. */ +// Session URLs are credentials too. SDK HTTP instrumentation records both full +// URLs and relative request targets, including query-based viewer/UI routes. +const LOCAL_INGEST_CAPABILITY = + /(\/_local\/v1\/sessions\/[^/?#\s"'\\]+\/ingest\/)[A-Za-z0-9_-]+/g; +const LOCAL_SCOPED_QUERY = + /\/_local\/v1\/sessions\/[^/?#\s"'\\]+\/(?:stream|ui\/(?:reset|close|retention))\?[^#\s"'\\]*/g; + const INVALID_BEARER_HEADER_START = /(\bHeaders\.(?:set|append):[ \t]*)(\\*["'])Bearer[ \t]+/gi; const INVALID_HEADER_END = /(? + url.replace(/([?&]cap=)[^&#\s"'\\]+/g, "$1[REDACTED]"), + ) .replace(QUOTED_CREDENTIAL, (_match, quote: string, prefix: string) => prefix.toLowerCase().startsWith("bearer") ? `${quote}Bearer [REDACTED]${quote}` diff --git a/packages/cli/test/commands/local/control-plane.test.ts b/packages/cli/test/commands/local/control-plane.test.ts new file mode 100644 index 000000000..f58dd5e85 --- /dev/null +++ b/packages/cli/test/commands/local/control-plane.test.ts @@ -0,0 +1,586 @@ +import { request } from "node:http"; +import { describe, expect, test } from "vitest"; +import { createLocalControlPlane } from "../../../src/commands/local/control-plane.js"; +import { tryListen } from "../../../src/commands/local/server.js"; + +const CONTROL_TOKEN = "control-token"; + +function controlHeaders() { + return { Authorization: `Bearer ${CONTROL_TOKEN}` }; +} + +describe("Local control plane", () => { + test("validates retention settings and keeps scoped mutation loopback-only", async () => { + const plane = createLocalControlPlane({ controlToken: CONTROL_TOKEN }); + const created = await plane.app.request("/_local/v1/sessions", { + method: "POST", + headers: controlHeaders(), + body: JSON.stringify({ label: "web", cwd: "/web", maxEnvelopes: 1000 }), + }); + const session = await created.json(); + expect(session.maxEnvelopes).toBe(1000); + const path = `/_local/v1/sessions/${session.id}/ui/retention?cap=${plane.sessionStore.getCapabilities(session.id).stream}`; + for (const origin of ["https://local.sentry.dev", "https://example.com"]) { + const result = await plane.app.request(path, { + method: "POST", + headers: { Origin: origin }, + body: JSON.stringify({ maxEnvelopes: 2000 }), + }); + expect(result.status).toBe(403); + } + const result = await plane.app.request(path, { + method: "POST", + headers: { Origin: "http://localhost:5173" }, + body: JSON.stringify({ maxEnvelopes: 2000 }), + }); + expect(result.status).toBe(200); + expect(await result.json()).toMatchObject({ maxEnvelopes: 2000 }); + const invalid = await plane.app.request(path, { + method: "POST", + body: JSON.stringify({ maxEnvelopes: 0 }), + }); + expect(invalid.status).toBe(400); + expect(plane.sessionStore.resolve(session.id).maxEnvelopes).toBe(2000); + }); + + test("reports ambiguous session names with ID guidance across control routes", async () => { + const plane = createLocalControlPlane({ controlToken: CONTROL_TOKEN }); + const one = plane.sessionStore.create({ label: "My App", cwd: "/one" }); + plane.sessionStore.create({ label: "My App", cwd: "/two" }); + for (const [method, suffix] of [ + ["GET", ""], + ["GET", "/events"], + ["GET", "/envelopes"], + ["GET", "/events/id"], + ["GET", "/envelopes/id"], + ["POST", "/close"], + ["POST", "/reset"], + ]) { + const response = await plane.app.request( + `/_local/v1/sessions/My%20App${suffix}`, + { method, headers: controlHeaders() }, + ); + expect(response.status).toBe(409); + await expect(response.json()).resolves.toEqual({ + error: 'Local session name "My App" is ambiguous; use its session ID.', + }); + } + expect( + ( + await plane.app.request(`/_local/v1/sessions/${one.id}`, { + headers: controlHeaders(), + }) + ).status, + ).toBe(200); + expect( + ( + await plane.app.request("/_local/v1/sessions/missing", { + headers: controlHeaders(), + }) + ).status, + ).toBe(404); + }); + + test("creates isolated sessions through its authenticated control API", async () => { + const controlPlane = createLocalControlPlane({ + controlToken: CONTROL_TOKEN, + }); + + const created = await controlPlane.app.request("/_local/v1/sessions", { + method: "POST", + headers: { ...controlHeaders(), "Content-Type": "application/json" }, + body: JSON.stringify({ label: "web", cwd: "/work/web" }), + }); + + expect(created.status).toBe(201); + const session = (await created.json()) as { + id: string; + label: string; + ingestUrl: string; + streamUrl: string; + }; + expect(session.label).toBe("web"); + expect(session.ingestUrl).toContain(`/${session.id}/ingest/`); + expect(new URL(session.ingestUrl).search).toBe(""); + expect(session.streamUrl).toContain(`/${session.id}/stream?cap=`); + expect(session.ingestUrl).not.toBe(session.streamUrl); + + const sessions = await controlPlane.app.request("/_local/v1/sessions", { + headers: controlHeaders(), + }); + await expect(sessions.json()).resolves.toMatchObject({ + sessions: [{ id: session.id, label: "web", state: "open" }], + }); + }); + + test("does not expose the session registry without the control capability", async () => { + const controlPlane = createLocalControlPlane({ + controlToken: CONTROL_TOKEN, + }); + + const response = await controlPlane.app.request("/_local/v1/sessions"); + + expect(response.status).toBe(401); + }); + + test("accepts envelopes only through the matching session ingestion capability", async () => { + const controlPlane = createLocalControlPlane({ + controlToken: CONTROL_TOKEN, + }); + const created = await controlPlane.app.request("/_local/v1/sessions", { + method: "POST", + headers: { ...controlHeaders(), "Content-Type": "application/json" }, + body: JSON.stringify({ label: "web", cwd: "/work/web" }), + }); + const session = (await created.json()) as { id: string; ingestUrl: string }; + const envelope = + '{"sdk":{"name":"sentry.node"}}\n{"type":"event"}\n{"message":"isolated"}'; + + const ingested = await controlPlane.app.request(session.ingestUrl, { + method: "POST", + headers: { "Content-Type": "application/x-sentry-envelope" }, + body: envelope, + }); + expect(ingested.status).toBe(204); + expect( + controlPlane.sessionStore.getBuffer(session.id).read({ all: true }), + ).toHaveLength(1); + + const rejected = await controlPlane.app.request( + session.ingestUrl.replace(/\/ingest\/[^/]+$/, "/ingest/wrong"), + { method: "POST", body: envelope }, + ); + expect(rejected.status).toBe(401); + }); + + test("protects pathname ingestion from read capabilities and remote browser writes", async () => { + const plane = createLocalControlPlane({ controlToken: CONTROL_TOKEN }); + const session = plane.sessionStore.create({ + label: "sdk", + cwd: "/work/sdk", + }); + const caps = plane.sessionStore.getCapabilities(session.id); + const path = `/_local/v1/sessions/${session.id}/ingest`; + expect( + (await plane.app.request(`${path}/${caps.stream}`, { method: "POST" })) + .status, + ).toBe(401); + expect( + ( + await plane.app.request(`${path}/wrong?cap=${caps.ingest}`, { + method: "POST", + }) + ).status, + ).toBe(401); + expect((await plane.app.request(`${path}/${caps.ingest}`)).status).toBe( + 401, + ); + for (const origin of ["https://local.sentry.dev", "https://example.com"]) { + expect( + ( + await plane.app.request(`${path}/${caps.ingest}`, { + method: "POST", + headers: { Origin: origin }, + }) + ).status, + ).toBe(403); + expect( + ( + await plane.app.request(`${path}/${caps.ingest}`, { + method: "OPTIONS", + headers: { + Origin: origin, + "Access-Control-Request-Method": "POST", + }, + }) + ).status, + ).toBe(403); + } + const preflight = await plane.app.request(`${path}/${caps.ingest}`, { + method: "OPTIONS", + headers: { + Origin: "http://localhost:5173", + "Access-Control-Request-Method": "POST", + }, + }); + expect(preflight.status).toBe(204); + expect(preflight.headers.get("access-control-allow-origin")).toBe( + "http://localhost:5173", + ); + }); + + test("rejects remote browser writes through the legacy scoped stream route", async () => { + const plane = createLocalControlPlane({ controlToken: CONTROL_TOKEN }); + const session = plane.sessionStore.create({ label: "web", cwd: "/web" }); + const cap = plane.sessionStore.getCapabilities(session.id).ingest; + const path = `/_local/v1/sessions/${session.id}/stream?cap=${cap}&sentry_client=sentry.javascript.browser/10.0`; + for (const origin of ["https://example.com", "https://local.sentry.dev"]) { + const response = await plane.app.request(path, { + method: "POST", + headers: { Origin: origin, "Content-Type": "text/plain" }, + body: '{"event_id":"origin-test"}\n{"type":"event"}\n{"message":"forged"}', + }); + expect(response.status).toBe(403); + expect( + plane.sessionStore.getBuffer(session.id).read({ all: true }), + ).toHaveLength(0); + expect( + ( + await plane.app.request(path, { + method: "OPTIONS", + headers: { + Origin: origin, + "Access-Control-Request-Method": "POST", + }, + }) + ).status, + ).toBe(403); + } + }); + + test("allows subscribers only through the session stream capability", async () => { + const controlPlane = createLocalControlPlane({ + controlToken: CONTROL_TOKEN, + }); + const created = await controlPlane.app.request("/_local/v1/sessions", { + method: "POST", + headers: { ...controlHeaders(), "Content-Type": "application/json" }, + body: JSON.stringify({ label: "web", cwd: "/work/web" }), + }); + const session = (await created.json()) as { streamUrl: string }; + + const rejected = await controlPlane.app.request( + session.streamUrl.replace(/cap=[^&]+/, "cap=wrong"), + ); + expect(rejected.status).toBe(401); + + const stream = await controlPlane.app.request(session.streamUrl); + expect(stream.status).toBe(200); + expect(stream.headers.get("content-type")).toContain("text/event-stream"); + await stream.body?.cancel(); + }); + + test("indexes decoded events by their Sentry event ID", async () => { + const controlPlane = createLocalControlPlane({ + controlToken: CONTROL_TOKEN, + }); + const created = await controlPlane.app.request("/_local/v1/sessions", { + method: "POST", + headers: { ...controlHeaders(), "Content-Type": "application/json" }, + body: JSON.stringify({ label: "web", cwd: "/work/web" }), + }); + const session = (await created.json()) as { id: string; ingestUrl: string }; + await controlPlane.app.request(session.ingestUrl, { + method: "POST", + body: '{"event_id":"event-123"}\n{"type":"event"}\n{"message":"isolated"}', + }); + + const listed = await controlPlane.app.request( + `/_local/v1/sessions/${session.id}/events`, + { headers: controlHeaders() }, + ); + await expect(listed.json()).resolves.toMatchObject({ + events: [{ eventId: "event-123" }], + }); + const viewed = await controlPlane.app.request( + `/_local/v1/sessions/${session.id}/events/event-123`, + { headers: controlHeaders() }, + ); + expect(viewed.status).toBe(200); + }); + + test.each([ + [ + { sdk: { name: "sentry.node" } }, + { type: "event", event_id: "item-id" }, + { event_id: "payload-id" }, + "item-id", + ], + [{}, { type: "transaction" }, { event_id: "payload-id" }, "payload-id"], + [ + { event_id: "header-id" }, + { type: "event", event_id: "item-id" }, + { event_id: "payload-id" }, + "header-id", + ], + ])( + "indexes SDK item IDs with envelope-header precedence", + async (header, item, payload, eventId) => { + const plane = createLocalControlPlane({ controlToken: CONTROL_TOKEN }); + const session = plane.sessionStore.create({ label: "sdk", cwd: "/sdk" }); + const { ingest } = plane.sessionStore.getCapabilities(session.id); + const response = await plane.app.request( + `/_local/v1/sessions/${session.id}/ingest/${ingest}`, + { + method: "POST", + body: [header, item, payload] + .map((value) => JSON.stringify(value)) + .join("\n"), + }, + ); + expect(response.status).toBe(204); + const listed = await plane.app.request( + `/_local/v1/sessions/${session.id}/events`, + { headers: controlHeaders() }, + ); + await expect(listed.json()).resolves.toMatchObject({ + events: [{ eventId }], + }); + const viewed = await plane.app.request( + `/_local/v1/sessions/${session.id}/events/${eventId}`, + { headers: controlHeaders() }, + ); + expect(viewed.status).toBe(200); + const data = await viewed.json(); + expect(data.envelopeId).toBeTruthy(); + const raw = await plane.app.request( + `/_local/v1/sessions/${session.id}/envelopes/${data.envelopeId}`, + { headers: controlHeaders() }, + ); + expect(raw.status).toBe(200); + }, + ); + + test("permits browser preflight only from a loopback development origin", async () => { + const controlPlane = createLocalControlPlane({ + controlToken: CONTROL_TOKEN, + }); + const created = await controlPlane.app.request("/_local/v1/sessions", { + method: "POST", + headers: { ...controlHeaders(), "Content-Type": "application/json" }, + body: JSON.stringify({ label: "web", cwd: "/work/web" }), + }); + const session = (await created.json()) as { ingestUrl: string }; + const response = await controlPlane.app.request(session.ingestUrl, { + method: "OPTIONS", + headers: { + Origin: "http://localhost:5173", + "Access-Control-Request-Method": "POST", + }, + }); + expect(response.status).toBe(204); + expect(response.headers.get("access-control-allow-origin")).toBe( + "http://localhost:5173", + ); + }); + + test("acknowledges a daemon stop request before closing its listener", async () => { + let server: import("node:http").Server | undefined; + const controlPlane = createLocalControlPlane({ + controlToken: CONTROL_TOKEN, + onStop: () => { + server?.closeAllConnections(); + server?.close(); + }, + }); + const listening = await tryListen(controlPlane.app, 0, "127.0.0.1"); + server = listening.server; + + const statusCode = await new Promise((resolve, reject) => { + const stopRequest = request( + `http://127.0.0.1:${listening.port}/_local/v1/daemon/stop`, + { method: "POST", headers: controlHeaders() }, + (response) => { + response.resume(); + response.on("end", () => resolve(response.statusCode ?? 0)); + }, + ); + stopRequest.on("error", reject); + stopRequest.end(); + }); + expect(statusCode).toBe(202); + }); + + test("lets a loopback UI reset and close only its scoped session", async () => { + const controlPlane = createLocalControlPlane({ + controlToken: CONTROL_TOKEN, + }); + const created = await controlPlane.app.request("/_local/v1/sessions", { + method: "POST", + headers: { ...controlHeaders(), "Content-Type": "application/json" }, + body: JSON.stringify({ label: "web", cwd: "/work/web" }), + }); + const session = (await created.json()) as { + id: string; + ingestUrl: string; + streamUrl: string; + }; + const resetUrl = session.streamUrl.replace(/\/stream\?/, "/ui/reset?"); + const closeUrl = session.streamUrl.replace(/\/stream\?/, "/ui/close?"); + await controlPlane.app.request(session.ingestUrl, { + method: "POST", + body: '{"event_id":"before-reset"}\n{"type":"event"}\n{"message":"kept private"}', + }); + expect( + controlPlane.sessionStore.getBuffer(session.id).read({ all: true }), + ).toHaveLength(1); + + const preflight = await controlPlane.app.request(resetUrl, { + method: "OPTIONS", + headers: { + Origin: "http://localhost:5173", + "Access-Control-Request-Method": "POST", + }, + }); + expect(preflight.status).toBe(204); + expect(preflight.headers.get("access-control-allow-origin")).toBe( + "http://localhost:5173", + ); + + const wrongCapability = await controlPlane.app.request( + resetUrl.replace(/cap=[^&]+/, "cap=wrong"), + { method: "POST", headers: { Origin: "http://localhost:5173" } }, + ); + expect(wrongCapability.status).toBe(401); + + expect( + ( + await controlPlane.app.request(resetUrl, { + method: "POST", + headers: { Origin: "http://localhost:5173" }, + }) + ).status, + ).toBe(204); + expect( + controlPlane.sessionStore.getBuffer(session.id).read({ all: true }), + ).toHaveLength(0); + + const rejected = await controlPlane.app.request(closeUrl, { + method: "POST", + headers: { Origin: "https://receiver.example" }, + }); + expect(rejected.status).toBe(403); + + const closed = await controlPlane.app.request(closeUrl, { + method: "POST", + headers: { Origin: "http://localhost:5173" }, + }); + expect(closed.status).toBe(200); + expect(controlPlane.sessionStore.resolve(session.id).state).toBe( + "retained", + ); + }); +}); + +describe("Local daemon receiver compatibility", () => { + test.each(["gzip", "deflate", "br"] as const)( + "decodes %s SDK envelopes", + async (encoding) => { + const { gzipSync, deflateSync, brotliCompressSync } = + await import("node:zlib"); + const plane = createLocalControlPlane({ controlToken: CONTROL_TOKEN }); + const session = plane.sessionStore.create({ + label: "compressed", + cwd: "/work", + }); + const capability = plane.sessionStore.getCapabilities(session.id).ingest; + const compress = { + gzip: gzipSync, + deflate: deflateSync, + br: brotliCompressSync, + }[encoding]; + const response = await plane.app.request( + `/_local/v1/sessions/${session.id}/stream?cap=${capability}`, + { + method: "POST", + headers: { + "Content-Type": "application/x-sentry-envelope", + "Content-Encoding": encoding, + }, + body: new Uint8Array( + compress( + '{"event_id":"compressed"}\n{"type":"event"}\n{"message":"compressed SDK"}', + ), + ), + }, + ); + expect(response.status).toBe(204); + const events = await plane.app.request( + `/_local/v1/sessions/${session.id}/events/compressed`, + { headers: controlHeaders() }, + ); + expect(events.status).toBe(200); + expect(await events.text()).toContain("compressed SDK"); + }, + ); + + test("supports sendBeacon browser SDK envelopes", async () => { + const plane = createLocalControlPlane({ controlToken: CONTROL_TOKEN }); + const session = plane.sessionStore.create({ + label: "browser", + cwd: "/work", + }); + const cap = plane.sessionStore.getCapabilities(session.id).ingest; + const ingested = await plane.app.request( + `/_local/v1/sessions/${session.id}/stream?cap=${cap}&sentry_client=sentry.javascript.browser/10.0`, + { + method: "POST", + headers: { + "Content-Type": "text/plain", + Origin: "http://localhost:3000", + }, + body: '{"event_id":"browser"}\n{"type":"event"}\n{"message":"browser SDK"}', + }, + ); + expect(ingested.status).toBe(204); + const response = await plane.app.request( + `/_local/v1/sessions/${session.id}/events/browser`, + { headers: controlHeaders() }, + ); + expect(response.status).toBe(200); + }); + + test("allows hosted viewer reads and private-network preflight while rejecting hosted mutations", async () => { + const plane = createLocalControlPlane({ controlToken: CONTROL_TOKEN }); + const session = plane.sessionStore.create({ + label: "hosted", + cwd: "/work", + }); + const cap = plane.sessionStore.getCapabilities(session.id).stream; + const path = `/_local/v1/sessions/${session.id}/stream?cap=${cap}`; + const preflight = await plane.app.request(path, { + method: "OPTIONS", + headers: { + Origin: "https://local.sentry.dev", + "Access-Control-Request-Method": "GET", + "Access-Control-Request-Headers": "Last-Event-ID", + "Access-Control-Request-Private-Network": "true", + }, + }); + expect(preflight.status).toBe(204); + expect(preflight.headers.get("Access-Control-Allow-Origin")).toBe( + "https://local.sentry.dev", + ); + expect(preflight.headers.get("Access-Control-Allow-Private-Network")).toBe( + "true", + ); + const response = await plane.app.request(path, { + headers: { Origin: "https://local.sentry.dev" }, + }); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "https://local.sentry.dev", + ); + await response.body?.cancel(); + const mutation = await plane.app.request(path, { + method: "POST", + headers: { Origin: "https://local.sentry.dev" }, + body: "invalid", + }); + expect(mutation.status).toBe(403); + }); +}); + +test("requires control authorization for a session named stream", async () => { + const plane = createLocalControlPlane({ controlToken: CONTROL_TOKEN }); + const session = plane.sessionStore.create({ + label: "stream", + cwd: "/work/stream", + }); + const response = await plane.app.request("/_local/v1/sessions/stream"); + expect(response.status).toBe(401); + const authorized = await plane.app.request( + `/_local/v1/sessions/${session.id}`, + { headers: controlHeaders() }, + ); + expect(authorized.status).toBe(200); +}); diff --git a/packages/cli/test/commands/local/daemon-invocation.test.ts b/packages/cli/test/commands/local/daemon-invocation.test.ts new file mode 100644 index 000000000..09ec58446 --- /dev/null +++ b/packages/cli/test/commands/local/daemon-invocation.test.ts @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { buildLocalDaemonArgs } from "../../../src/commands/local/daemon.js"; + +const runtime = vi.hoisted(() => ({ sea: false })); +vi.mock("../../../src/lib/react-native/wrap-call.js", () => ({ + isSea: () => runtime.sea, +})); +afterEach(() => { + runtime.sea = false; + vi.unstubAllGlobals(); +}); + +describe("Local daemon invocation", () => { + test("re-executes a native CLI without adding a script or Node flags", () => { + runtime.sea = true; + vi.stubGlobal("process", { + ...process, + argv: ["/sentry", "local", "session", "create"], + execArgv: ["--experimental-sea-config"], + }); + expect(buildLocalDaemonArgs("127.0.0.1", 9000)).toEqual([ + "local", + "daemon", + "start", + "--foreground", + "--host", + "127.0.0.1", + "--port", + "9000", + ]); + }); + test("preserves the Node script and loader flags for source and npm builds", () => { + vi.stubGlobal("process", { + ...process, + argv: ["/node", "/cli/bin.cjs", "local"], + execArgv: ["--import", "tsx"], + }); + expect(buildLocalDaemonArgs("localhost", 9000)).toEqual([ + "--import", + "tsx", + "/cli/bin.cjs", + "local", + "daemon", + "start", + "--foreground", + "--host", + "localhost", + "--port", + "9000", + ]); + }); +}); diff --git a/packages/cli/test/commands/local/daemon-lifecycle.test.ts b/packages/cli/test/commands/local/daemon-lifecycle.test.ts new file mode 100644 index 000000000..8e94de4e3 --- /dev/null +++ b/packages/cli/test/commands/local/daemon-lifecycle.test.ts @@ -0,0 +1,175 @@ +/** Daemon identity and process invocation regression coverage. */ +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { createServer } from "node:http"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { useTestConfigDir } from "../../helpers.js"; +import { + readLocalDaemonState, + getRunningLocalDaemon, + writeLocalDaemonState, + runLocalDaemonForeground, +} from "../../../src/commands/local/daemon.js"; + +const getConfigDir = useTestConfigDir("local-daemon-lifecycle-"); +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("Local daemon discovery", () => { + test("rejects stale state when another service answers the public health endpoint", async () => { + vi.stubGlobal( + "fetch", + (globalThis as { __originalFetch: typeof fetch }).__originalFetch, + ); + vi.spyOn(process, "kill").mockImplementation(() => { + throw Object.assign(new Error("owner exited"), { code: "ESRCH" }); + }); + const server = createServer((request, response) => { + response.statusCode = request.url === "/health" ? 200 : 404; + response.end("OK"); + }); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + const address = server.address(); + if (!address || typeof address === "string") + throw new Error("Expected a TCP address"); + try { + await writeLocalDaemonState(getConfigDir(), { + host: "127.0.0.1", + port: address.port, + pid: process.pid, + token: "stale", + version: 1, + }); + await expect(getRunningLocalDaemon()).resolves.toBeUndefined(); + } finally { + await new Promise((resolve) => { + server.close(resolve); + server.closeAllConnections(); + }); + } + }); + + test("preserves live daemon discovery after a failed probe and recovers on retry", async () => { + const state = { + host: "127.0.0.1", + port: 8969, + pid: process.pid, + token: "live", + version: 1, + }; + await writeLocalDaemonState(getConfigDir(), state); + const probe = vi + .fn() + .mockRejectedValueOnce(new Error("temporarily unavailable")) + .mockResolvedValueOnce( + new Response(JSON.stringify({ sessions: [] }), { status: 200 }), + ); + vi.stubGlobal("fetch", probe); + await expect(getRunningLocalDaemon()).rejects.toThrow("still running"); + await expect(readLocalDaemonState(getConfigDir())).resolves.toEqual(state); + await expect(getRunningLocalDaemon()).resolves.toEqual(state); + }); + + test("preserves discovery when the process existence check is permission denied", async () => { + const state = { + host: "127.0.0.1", + port: 8969, + pid: 42, + token: "protected", + version: 1, + }; + await writeLocalDaemonState(getConfigDir(), state); + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("unreachable"))); + vi.spyOn(process, "kill").mockImplementation(() => { + throw Object.assign(new Error("permission denied"), { code: "EPERM" }); + }); + await expect(getRunningLocalDaemon()).rejects.toThrow("still running"); + await expect(readLocalDaemonState(getConfigDir())).resolves.toEqual(state); + }); + + test("rediscovering a dead snapshot does not erase a replacement daemon", async () => { + const stale = { + host: "127.0.0.1", + port: 8969, + pid: 42, + token: "old", + version: 1, + }; + const replacement = { ...stale, pid: process.pid, token: "replacement" }; + const realKill = process.kill; + vi.spyOn(process, "kill").mockImplementation((pid, signal) => { + if (pid === stale.pid) + throw Object.assign(new Error("owner exited"), { code: "ESRCH" }); + return realKill(pid, signal); + }); + let finishProbe: ((response: Response) => void) | undefined; + const probe = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishProbe = resolve; + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ sessions: [] }), { status: 200 }), + ); + vi.stubGlobal("fetch", probe); + await writeLocalDaemonState(getConfigDir(), stale); + const pending = getRunningLocalDaemon(); + await expect.poll(() => probe.mock.calls.length).toBe(1); + await writeLocalDaemonState(getConfigDir(), replacement); + if (!finishProbe) throw new Error("Missing deferred probe"); + finishProbe(new Response("wrong service", { status: 404 })); + await expect(pending).resolves.toEqual(replacement); + await expect(readLocalDaemonState(getConfigDir())).resolves.toEqual( + replacement, + ); + }); + + test("rejects non-loopback discovery without making a request", async () => { + const mock = vi.fn(); + vi.stubGlobal("fetch", mock); + await writeLocalDaemonState(getConfigDir(), { + host: "example.com", + port: 8969, + pid: process.pid, + token: "secret", + version: 1, + }); + await expect(getRunningLocalDaemon()).resolves.toBeUndefined(); + expect(mock).not.toHaveBeenCalled(); + }); + + test("rejects port zero in foreground mode", async () => { + await expect( + runLocalDaemonForeground({ host: "127.0.0.1", port: 0 }), + ).rejects.toThrow("non-zero"); + }); +}); + +test("closes the listener when discovery persistence fails", async () => { + const reserve = createServer(); + await new Promise((resolve) => reserve.listen(0, "127.0.0.1", resolve)); + const address = reserve.address(); + if (!address || typeof address === "string") + throw new Error("Expected a TCP address"); + await new Promise((resolve) => reserve.close(() => resolve())); + await mkdir(join(getConfigDir(), "local-control-plane.json")); + await expect( + runLocalDaemonForeground({ host: "127.0.0.1", port: address.port }), + ).rejects.toThrow(); + const replacement = createServer(); + try { + await new Promise((resolve, reject) => { + replacement.once("error", reject); + replacement.listen(address.port, "127.0.0.1", resolve); + }); + } finally { + await new Promise((resolve) => replacement.close(() => resolve())); + } +}); diff --git a/packages/cli/test/commands/local/daemon-ownership.test.ts b/packages/cli/test/commands/local/daemon-ownership.test.ts new file mode 100644 index 000000000..682ed2ded --- /dev/null +++ b/packages/cli/test/commands/local/daemon-ownership.test.ts @@ -0,0 +1,170 @@ +import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { useTestConfigDir } from "../../helpers.js"; +import { claimDaemonOwnership } from "../../../src/commands/local/daemon.js"; + +vi.mock("node:fs/promises", async (importOriginal) => { + const fs = await importOriginal(); + return { ...fs, readFile: vi.fn(fs.readFile), stat: vi.fn(fs.stat) }; +}); +const getConfigDir = useTestConfigDir("local-owner-race-"); +afterEach(() => vi.restoreAllMocks()); + +describe("Local daemon ownership", () => { + test.each(["read", "stat"])( + "retries when ownership disappears during %s", + async (stage) => { + const directory = getConfigDir(); + await mkdir(directory, { recursive: true }); + const owner = join(directory, "local-control-plane.owner"); + await writeFile(owner, ""); + const disappear = async () => { + await rm(owner); + throw Object.assign(new Error("owner released"), { code: "ENOENT" }); + }; + if (stage === "read") + vi.mocked(readFile).mockImplementationOnce(disappear); + else vi.mocked(stat).mockImplementationOnce(disappear); + const release = await claimDaemonOwnership(directory); + expect(await readFile(owner, "utf8")).toBe(String(process.pid)); + await release(); + }, + ); + + test("claims an invalid owner that expires on the last recovery inspection", async () => { + const fs = + await vi.importActual( + "node:fs/promises", + ); + const directory = getConfigDir(); + await mkdir(directory, { recursive: true }); + const owner = join(directory, "local-control-plane.owner"); + await writeFile(owner, ""); + const snapshot = await fs.stat(owner); + let inspection = 0; + // Hold the invalid file inside its grace period until the retry boundary. + vi.spyOn(Date, "now").mockImplementation( + () => snapshot.mtimeMs + (++inspection < 40 ? 5000 : 5001), + ); + const release = await claimDaemonOwnership(directory); + try { + expect(await fs.readFile(owner, "utf8")).toBe(String(process.pid)); + } finally { + await release(); + } + }); + + test("preserves a recent invalid owner while its grace period has not elapsed", async () => { + const fs = + await vi.importActual( + "node:fs/promises", + ); + const directory = getConfigDir(); + await mkdir(directory, { recursive: true }); + const owner = join(directory, "local-control-plane.owner"); + await writeFile(owner, ""); + const snapshot = await fs.stat(owner); + vi.spyOn(Date, "now").mockReturnValue(snapshot.mtimeMs + 5000); + await expect(claimDaemonOwnership(directory)).rejects.toThrow(/Timed out/); + expect(await fs.readFile(owner, "utf8")).toBe(""); + }); + + test("preserves a replacement live owner when its old snapshot is dead", async () => { + const directory = getConfigDir(); + await mkdir(directory, { recursive: true }); + const owner = join(directory, "local-control-plane.owner"); + await writeFile(owner, String(process.pid)); + // A stale reader's old PID arrives after a new owner replaced the file. + vi.mocked(readFile).mockResolvedValueOnce("42"); + const actualKill = process.kill; + vi.spyOn(process, "kill").mockImplementation((pid, signal) => { + if (pid === 42) + throw Object.assign(new Error("exited"), { code: "ESRCH" }); + return actualKill(pid, signal); + }); + await expect(claimDaemonOwnership(directory)).rejects.toThrow( + "already running", + ); + expect(await readFile(owner, "utf8")).toBe(String(process.pid)); + }); + + test("serializes another claimant while stale ownership is being reclaimed", async () => { + const fs = + await vi.importActual( + "node:fs/promises", + ); + const directory = getConfigDir(); + await mkdir(directory, { recursive: true }); + const owner = join(directory, "local-control-plane.owner"); + await writeFile(owner, "42"); + const actualKill = process.kill; + vi.spyOn(process, "kill").mockImplementation((pid, signal) => { + if (pid === 42) + throw Object.assign(new Error("exited"), { code: "ESRCH" }); + return actualKill(pid, signal); + }); + let contender: Promise<() => Promise> | undefined; + vi.mocked(stat).mockImplementationOnce(async () => { + expect( + await fs.readFile( + join(directory, "local-control-plane.owner.guard"), + "utf8", + ), + ).toBe(String(process.pid)); + contender = claimDaemonOwnership(directory); + return fs.stat(owner); + }); + const first = claimDaemonOwnership(directory); + const firstResult = first.then( + (release) => ({ release }), + (error: unknown) => ({ error }), + ); + await expect.poll(() => contender !== undefined).toBe(true); + if (!contender) throw new Error("Missing concurrent claimant"); + const results = await Promise.all([ + firstResult, + contender.then( + (release) => ({ release }), + (error: unknown) => ({ error }), + ), + ]); + expect(results.filter((result) => "release" in result)).toHaveLength(1); + expect(results.filter((result) => "error" in result)).toHaveLength(1); + expect(await fs.readFile(owner, "utf8")).toBe(String(process.pid)); + for (const result of results) + if ("release" in result) await result.release(); + }); + + test("preserves ownership when a short arbitration guard was interrupted", async () => { + const directory = getConfigDir(); + await mkdir(directory, { recursive: true }); + const guard = join(directory, "local-control-plane.owner.guard"); + const owner = join(directory, "local-control-plane.owner"); + await writeFile(guard, "42"); + await writeFile(owner, String(process.pid)); + const actualKill = process.kill; + vi.spyOn(process, "kill").mockImplementation((pid, signal) => { + if (pid === 42) + throw Object.assign(new Error("exited"), { code: "ESRCH" }); + return actualKill(pid, signal); + }); + await expect(claimDaemonOwnership(directory)).rejects.toThrow( + "arbitration was interrupted", + ); + expect(await readFile(owner, "utf8")).toBe(String(process.pid)); + expect(await readFile(guard, "utf8")).toBe("42"); + }); + + test("propagates an unreadable owner without removing it", async () => { + const directory = getConfigDir(); + await mkdir(directory, { recursive: true }); + const owner = join(directory, "local-control-plane.owner"); + await writeFile(owner, "42"); + vi.mocked(readFile).mockRejectedValueOnce( + Object.assign(new Error("denied"), { code: "EACCES" }), + ); + await expect(claimDaemonOwnership(directory)).rejects.toThrow("denied"); + expect(await readFile(owner, "utf8")).toBe("42"); + }); +}); diff --git a/packages/cli/test/commands/local/daemon-startup.test.ts b/packages/cli/test/commands/local/daemon-startup.test.ts new file mode 100644 index 000000000..2e76ac7ae --- /dev/null +++ b/packages/cli/test/commands/local/daemon-startup.test.ts @@ -0,0 +1,91 @@ +/** Startup joins a live foreground owner while authenticated discovery is pending. */ +import { type ChildProcess, spawn } from "node:child_process"; +import { EventEmitter } from "node:events"; +import { readFile } from "node:fs/promises"; +import { writeFileSync } from "node:fs"; +import { createServer } from "node:http"; +import { join } from "node:path"; +import { setTimeout as sleep } from "node:timers/promises"; +import { afterEach, expect, test, vi } from "vitest"; +import { useTestConfigDir } from "../../helpers.js"; +import { + ensureLocalDaemon, + writeLocalDaemonState, +} from "../../../src/commands/local/daemon.js"; + +vi.mock("node:child_process", async (importOriginal) => { + const cp = await importOriginal(); + return { ...cp, spawn: vi.fn() }; +}); +const getConfigDir = useTestConfigDir("local-foreground-startup-"); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.mocked(spawn).mockReset(); +}); + +test.each(["before startup", "after spawn"])( + "joins live foreground ownership appearing %s without surfacing the losing child's exit", + async (stage) => { + vi.stubGlobal( + "fetch", + (globalThis as { __originalFetch: typeof fetch }).__originalFetch, + ); + const server = createServer((request, response) => { + response.statusCode = + request.url === "/_local/v1/sessions" && + request.headers.authorization === "Bearer foreground-token" + ? 200 + : 401; + response.setHeader("Content-Type", "application/json"); + response.end(JSON.stringify({ sessions: [] })); + }); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + const address = server.address(); + if (!address || typeof address === "string") + throw new Error("Expected TCP address"); + const directory = getConfigDir(); + const owner = join(directory, "local-control-plane.owner"); + const state = { + host: "127.0.0.1", + port: address.port, + pid: process.pid, + token: "foreground-token", + version: 1, + }; + let publishing: Promise | undefined; + const beginForeground = () => { + writeFileSync(owner, String(process.pid)); + publishing = sleep(300).then(() => + writeLocalDaemonState(directory, state), + ); + }; + if (stage === "before startup") beginForeground(); + vi.mocked(spawn).mockImplementation(() => { + if (stage === "after spawn") beginForeground(); + const child = Object.assign(new EventEmitter(), { + kill: vi.fn(() => true), + unref: vi.fn(), + }) as unknown as ChildProcess; + queueMicrotask(() => child.emit("exit", 1, null)); + return child; + }); + try { + await expect( + ensureLocalDaemon({ host: "127.0.0.1", port: address.port }), + ).resolves.toEqual(state); + expect(await readFile(owner, "utf8")).toBe(String(process.pid)); + await expect( + readFile(join(directory, "local-control-plane.lock"), "utf8"), + ).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + await publishing; + await new Promise((resolve) => { + server.close(() => resolve()); + server.closeAllConnections(); + }); + } + }, +); diff --git a/packages/cli/test/commands/local/daemon-state.test.ts b/packages/cli/test/commands/local/daemon-state.test.ts new file mode 100644 index 000000000..b856091e8 --- /dev/null +++ b/packages/cli/test/commands/local/daemon-state.test.ts @@ -0,0 +1,39 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; +import { + readLocalDaemonState, + removeLocalDaemonState, + writeLocalDaemonState, +} from "../../../src/commands/local/daemon.js"; + +const directories: string[] = []; + +afterEach(async () => { + await Promise.all( + directories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +describe("Local daemon state", () => { + test("stores daemon discovery and the control capability in a private state file", async () => { + const directory = await mkdtemp(join(tmpdir(), "sentry-local-state-")); + directories.push(directory); + const state = { + host: "127.0.0.1", + port: 8969, + pid: 42, + token: "secret", + version: 1, + }; + + await writeLocalDaemonState(directory, state); + + await expect(readLocalDaemonState(directory)).resolves.toEqual(state); + await removeLocalDaemonState(directory); + await expect(readLocalDaemonState(directory)).resolves.toBeUndefined(); + }); +}); diff --git a/packages/cli/test/commands/local/run.test.ts b/packages/cli/test/commands/local/run.test.ts index 9f432ba2f..4aae718c2 100644 --- a/packages/cli/test/commands/local/run.test.ts +++ b/packages/cli/test/commands/local/run.test.ts @@ -67,6 +67,7 @@ type RunFunc = ( attributes?: boolean; filter?: ("error" | "transaction" | "log" | "ai")[]; open?: boolean; + session?: string; }, ...args: string[] ) => Promise; @@ -212,6 +213,27 @@ describe("sentry local run", () => { }); }); + test("rejects --session with --verify before starting a receiver", async () => { + const func = (await runCommand.loader()) as unknown as RunFunc; + + await expect( + func.call( + makeContext(), + { + port: 0, + host: "localhost", + verify: true, + timeout: 0, + session: "verification", + }, + "true", + ), + ).rejects.toMatchObject({ + field: "session", + message: "--session cannot be used with --verify.", + }); + }); + test("rejects a non-loopback --open host before starting a receiver", async () => { const func = (await runCommand.loader()) as unknown as RunFunc; diff --git a/packages/cli/test/commands/local/server.test.ts b/packages/cli/test/commands/local/server.test.ts index a0ed06db2..3519c03e4 100644 --- a/packages/cli/test/commands/local/server.test.ts +++ b/packages/cli/test/commands/local/server.test.ts @@ -6,10 +6,13 @@ */ import { createSpotlightBuffer } from "@spotlightjs/spotlight/sdk"; +import { createLocalRetentionBuffer } from "../../../src/commands/local/retention-buffer.js"; import { Hono } from "hono"; import { describe, expect, test, vi } from "vitest"; import { buildApp, + consumeSSE, + configureExistingRetention, feedSSELine, isServerRunning, parsePort, @@ -77,6 +80,34 @@ function interceptShutdownSignal() { }; } +test("reports the scoped SSE endpoint without leaking its capability on initial attach failure", async () => { + const savedFetch = globalThis.fetch; + globalThis.fetch = vi + .fn() + .mockResolvedValue(new Response(null, { status: 401 })); + const writes: string[] = []; + const spy = vi + .spyOn(process.stderr, "write") + .mockImplementation((chunk: string | Uint8Array) => { + writes.push(chunk.toString()); + return true; + }); + try { + await consumeSSE({ + url: "http://127.0.0.1:8969", + streamUrl: + "http://127.0.0.1:8969/_local/v1/sessions/session-id/stream?cap=fixture-secret", + activeFilters: new Set(), + signal: new AbortController().signal, + }); + } finally { + globalThis.fetch = savedFetch; + spy.mockRestore(); + } + expect(writes.join("")).toContain("/_local/v1/sessions/session-id/stream"); + expect(writes.join("")).not.toContain("fixture-secret"); +}); + describe("parsePort", () => { test("parses valid port numbers", () => { expect(parsePort("8969")).toBe(8969); @@ -318,6 +349,56 @@ async function readFirstSSEEvent(streamPath: string): Promise { } describe("buildApp", () => { + test("changes legacy retention only when explicit and preserves history on rejected decreases", async () => { + const retention = createLocalRetentionBuffer(2); + const app = buildApp(retention.buffer, { uiActions: true, retention }); + await app.request("/stream", { + method: "POST", + headers: { "Content-Type": SENTRY_CONTENT_TYPE }, + body: TEST_ENVELOPE, + }); + const retained = retention.buffer.read({ all: true }); + for (const origin of ["https://example.com", "https://local.sentry.dev"]) { + expect( + ( + await app.request("/retention", { + method: "POST", + headers: { Origin: origin }, + body: '{"maxEnvelopes":1000}', + }) + ).status, + ).toBe(403); + } + const fetchMock = vi.fn(async (url: string, init?: RequestInit) => + app.request(new URL(url).pathname, init), + ); + vi.stubGlobal("fetch", fetchMock); + try { + await configureExistingRetention("http://127.0.0.1:8969"); + expect(fetchMock).not.toHaveBeenCalled(); + await configureExistingRetention("http://127.0.0.1:8969", 1000); + expect(retention.getSettings().maxEnvelopes).toBe(1000); + expect(retention.buffer.read({ all: true })).toEqual(retained); + await expect( + configureExistingRetention("http://127.0.0.1:8969", 0), + ).rejects.toThrow(/integer/); + fetchMock.mockImplementation( + async () => new Response("{}", { status: 200 }), + ); + await expect( + configureExistingRetention("http://127.0.0.1:8969", 2000), + ).rejects.toThrow(/did not confirm/); + fetchMock.mockImplementation( + async () => new Response(null, { status: 404 }), + ); + await expect( + configureExistingRetention("http://127.0.0.1:8969", 2000), + ).rejects.toThrow(/does not support/); + } finally { + vi.unstubAllGlobals(); + } + }); + test("health endpoint returns OK", async () => { const buffer = createSpotlightBuffer(10); const app = buildApp(buffer); diff --git a/packages/cli/test/commands/local/session-store.test.ts b/packages/cli/test/commands/local/session-store.test.ts new file mode 100644 index 000000000..91f51239a --- /dev/null +++ b/packages/cli/test/commands/local/session-store.test.ts @@ -0,0 +1,144 @@ +import { pushToSpotlightBuffer } from "@spotlightjs/spotlight/sdk"; +import { describe, expect, test, vi } from "vitest"; +import { createLocalSessionStore } from "../../../src/commands/local/session-store.js"; + +describe("LocalSessionStore", () => { + test("grows retention without losing envelopes, IDs or live subscribers", () => { + const store = createLocalSessionStore(); + const session = store.create({ + label: "web", + cwd: "/web", + maxEnvelopes: 2, + }); + const buffer = store.getBuffer(session.id); + const messages: string[] = []; + const reader = buffer.subscribe((container) => + messages.push(container.getData().toString()), + ); + function ingest(id: string) { + pushToSpotlightBuffer({ + spotlightBuffer: buffer, + contentType: "application/x-sentry-envelope", + body: Buffer.from( + `{"event_id":"${id}"}\n{"type":"event"}\n{"message":"${id}"}`, + ), + }); + buffer.stream(reader); + } + ingest("one"); + ingest("two"); + const retained = buffer.read({ all: true }); + expect(store.resolve(session.id).maxEnvelopes).toBe(2); + store.setMaxEnvelopes(session.id, 4); + expect(store.getBuffer(session.id)).toBe(buffer); + expect(buffer.read({ all: true })).toEqual(retained); + ingest("three"); + ingest("four"); + expect(buffer.read({ all: true })).toHaveLength(4); + expect(messages).toHaveLength(4); + expect(() => store.setMaxEnvelopes(session.id, 3)).toThrow( + /retained envelopes/, + ); + expect(buffer.read({ all: true })).toHaveLength(4); + buffer.clear(); + store.setMaxEnvelopes(session.id, 1); + ingest("five"); + ingest("six"); + expect(buffer.read({ all: true })).toHaveLength(1); + expect(messages).toHaveLength(6); + buffer.unsubscribe(reader); + }); + + test("keeps original arrival times when resizing", () => { + const store = createLocalSessionStore(); + const session = store.create({ + label: "timed", + cwd: "/timed", + maxEnvelopes: 2, + }); + const buffer = store.getBuffer(session.id); + const time = vi.spyOn(Date, "now").mockReturnValue(1000); + try { + pushToSpotlightBuffer({ + spotlightBuffer: buffer, + contentType: "application/x-sentry-envelope", + body: Buffer.from('{"event_id":"old"}\n{"type":"event"}\n{}'), + }); + time.mockReturnValue(61001); + store.setMaxEnvelopes(session.id, 4); + expect(buffer.read({ timeWindow: 60 })).toHaveLength(0); + expect(buffer.read({ all: true })).toHaveLength(1); + } finally { + time.mockRestore(); + } + }); + + test("creates isolated sessions with stable IDs and readable labels", () => { + const store = createLocalSessionStore({ now: () => 1000 }); + + const web = store.create({ label: "web", cwd: "/work/web" }); + const api = store.create({ label: "api", cwd: "/work/api" }); + + expect(web.id).not.toBe(api.id); + expect(web.label).toBe("web"); + expect(store.resolve(web.id)).toMatchObject({ id: web.id, label: "web" }); + expect(store.resolve("api")).toMatchObject({ + id: api.id, + cwd: "/work/api", + }); + }); + + test("rejects ambiguous labels instead of selecting an arbitrary session", () => { + const store = createLocalSessionStore({ now: () => 1000 }); + store.create({ label: "web", cwd: "/work/one" }); + store.create({ label: "web", cwd: "/work/two" }); + + expect(() => store.resolve("web")).toThrow(/ambiguous/i); + }); + + test("reuses a session when an agent retries with the same client ID", () => { + const store = createLocalSessionStore(); + const first = store.create({ + clientId: "agent-turn-7", + label: "web", + cwd: "/work/web", + }); + const retried = store.create({ + clientId: "agent-turn-7", + label: "web", + cwd: "/work/web", + }); + + expect(retried.id).toBe(first.id); + expect(store.list()).toHaveLength(1); + }); + + test("retains a closed session for three hours and then expires it", () => { + let now = 1000; + const store = createLocalSessionStore({ now: () => now }); + const session = store.create({ label: "web", cwd: "/work/web" }); + + store.close(session.id); + now += 3 * 60 * 60 * 1000 - 1; + expect(store.pruneExpired()).toEqual([]); + expect(store.resolve(session.id).state).toBe("retained"); + + now += 1; + expect(store.pruneExpired()).toEqual([session.id]); + expect(() => store.resolve(session.id)).toThrow(/not found/i); + }); + + test("refreshes the retention deadline when a retained session receives activity", () => { + let now = 1000; + const store = createLocalSessionStore({ now: () => now }); + const session = store.create({ label: "web", cwd: "/work/web" }); + store.close(session.id); + + now += 2 * 60 * 60 * 1000; + store.recordActivity(session.id); + now += 2 * 60 * 60 * 1000; + + expect(store.pruneExpired()).toEqual([]); + expect(store.resolve(session.id).state).toBe("retained"); + }); +}); diff --git a/packages/cli/test/e2e/local.test.ts b/packages/cli/test/e2e/local.test.ts new file mode 100644 index 000000000..5919f65ac --- /dev/null +++ b/packages/cli/test/e2e/local.test.ts @@ -0,0 +1,1008 @@ +/** Real CLI process coverage for daemon discovery, isolated telemetry and leases. */ +import { createServer } from "node:net"; +import { createServer as createHttpServer } from "node:http"; +import { rmSync } from "node:fs"; +import { readFile, stat, rm, utimes, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { setTimeout as sleep } from "node:timers/promises"; +import { afterEach, describe, expect, test } from "vitest"; +import { runCli } from "../fixture.js"; +import { useTestConfigDir } from "../helpers.js"; + +/** Capability URLs returned explicitly by session creation. */ +type Session = { + id: string; + ingestUrl: string; + streamUrl: string; +}; + +const fetch = (globalThis as { __originalFetch: typeof globalThis.fetch }) + .__originalFetch; +const getConfigDir = useTestConfigDir("local-e2e-"); + +async function cli(...args: string[]) { + return runCli(["local", ...args, "--json"], { + env: { + SENTRY_CONFIG_DIR: getConfigDir(), + SENTRY_CLI_NO_TELEMETRY: "1", + SENTRY_AUTH_TOKEN: "", + SENTRY_TOKEN: "", + }, + }); +} + +async function availablePort(): Promise { + const server = createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Expected a TCP address"); + } + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + return address.port; +} + +async function createSession( + label: string, + port: number, + maxEnvelopes?: number, +): Promise { + const result = await cli( + "session", + "create", + label, + "--host", + "127.0.0.1", + "--port", + String(port), + ...(maxEnvelopes === undefined + ? [] + : ["--max-envelopes", String(maxEnvelopes)]), + ); + expect(result.exitCode, result.stderr).toBe(0); + return JSON.parse(result.stdout) as Session; +} + +async function ingest(session: Session, eventId: string, message: string) { + const response = await fetch(session.ingestUrl, { + method: "POST", + headers: { "Content-Type": "application/x-sentry-envelope" }, + body: `${JSON.stringify({ event_id: eventId })}\n{"type":"event"}\n${JSON.stringify({ event_id: eventId, message })}`, + }); + expect(response.status).toBe(204); +} + +afterEach(async () => { + await cli("daemon", "stop"); + await expect + .poll( + async () => { + const status = await cli("daemon", "status"); + return JSON.parse(status.stdout) as unknown; + }, + { timeout: 10_000 }, + ) + .toEqual({ daemon: null }); +}); + +describe("Local daemon E2E", () => { + test.each(["delayed", "rejected", "never", "hung"])( + "restart handles %s shutdown without reporting the old daemon as restarted", + async (mode) => { + const token = "restart-fixture"; + let cleanupTimer: ReturnType | undefined; + const statePath = join(getConfigDir(), "local-control-plane.json"); + const ownerPath = join(getConfigDir(), "local-control-plane.owner"); + const server = createHttpServer((request, response) => { + if (request.url?.endsWith("/daemon/stop")) { + if (mode === "hung") return; + response.statusCode = mode === "rejected" ? 401 : 202; + response.end(); + if (mode === "delayed") { + setImmediate(() => { + server.close(); + server.closeAllConnections(); + }); + cleanupTimer = setTimeout(() => { + rmSync(statePath, { force: true }); + rmSync(ownerPath, { force: true }); + }, 350); + } + } else { + response.setHeader("Content-Type", "application/json"); + response.end(JSON.stringify({ sessions: [] })); + } + }); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + const address = server.address(); + if (!address || typeof address === "string") + throw new Error("Expected TCP address"); + const before = { + host: "127.0.0.1", + port: address.port, + pid: process.pid, + token, + version: 1, + }; + await writeFile(statePath, JSON.stringify(before)); + await writeFile(ownerPath, String(process.pid)); + try { + const result = await cli("daemon", "restart"); + if (mode === "delayed") { + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + host: before.host, + port: before.port, + }); + const after = JSON.parse(await readFile(statePath, "utf8")) as { + pid: number; + }; + expect(after.pid).not.toBe(before.pid); + } else { + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain( + mode === "rejected" ? "HTTP 401" : "Timed out", + ); + expect(JSON.parse(await readFile(statePath, "utf8"))).toEqual(before); + } + } finally { + if (cleanupTimer) clearTimeout(cleanupTimer); + if (server.listening) + await new Promise((resolve) => { + server.close(() => resolve()); + server.closeAllConnections(); + }); + const remaining = await readFile(statePath, "utf8").catch(() => "null"); + const current = JSON.parse(remaining) as { + pid?: number; + token?: string; + } | null; + if (current?.pid === before.pid && current.token === before.token) { + await rm(statePath, { force: true }); + await rm(ownerPath, { force: true }); + } + } + }, + 30_000, + ); + + test("surfaces duplicate session name guidance through the CLI", async () => { + const port = await availablePort(); + const first = await createSession("duplicate", port); + await createSession("duplicate", port); + for (const parts of [ + ["session", "view"], + ["session", "close"], + ["event", "list"], + ["envelope", "list"], + ]) { + const result = await cli(...parts, "duplicate"); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("ambiguous; use its session ID"); + } + expect((await cli("session", "view", first.id)).exitCode).toBe(0); + }, 30_000); + + test("isolates sessions and capabilities, queries retained telemetry and clears only one session", async () => { + const port = await availablePort(); + const [web, worker] = await Promise.all([ + createSession("web", port), + createSession("worker", port), + ]); + expect(web.id).not.toBe(worker.id); + const statePath = join(getConfigDir(), "local-control-plane.json"); + const state = JSON.parse(await readFile(statePath, "utf8")) as { + token: string; + host: string; + port: number; + }; + if (process.platform !== "win32") { + expect((await stat(statePath)).mode % 0o1000).toBe(0o600); + } + const status = await cli("daemon", "status"); + expect(status.stdout).not.toContain(state.token); + const base = `http://${state.host}:${state.port}/_local/v1`; + expect((await fetch(`${base}/sessions`)).status).toBe(401); + expect( + (await fetch(web.streamUrl, { method: "POST", body: "invalid" })).status, + ).toBe(401); + const wrongSession = new URL(web.ingestUrl); + wrongSession.pathname = wrongSession.pathname.replace(web.id, worker.id); + expect( + (await fetch(wrongSession, { method: "POST", body: "invalid" })).status, + ).toBe(401); + + await ingest(web, "a".repeat(32), "web event"); + await ingest(worker, "b".repeat(32), "worker event"); + const listed = await cli("event", "list", "web"); + expect(listed.exitCode, listed.stderr).toBe(0); + const events = JSON.parse(listed.stdout) as { + events: { eventId: string; envelopeId: string }[]; + }; + expect(events.events).toHaveLength(1); + expect(events.events[0]?.eventId).toBe("a".repeat(32)); + const viewed = await cli("event", "view", web.id, "a".repeat(32)); + expect(viewed.exitCode, viewed.stderr).toBe(0); + expect(viewed.stdout).toContain("web event"); + expect(viewed.stdout).not.toContain("worker event"); + const raw = await cli( + "envelope", + "view", + web.id, + events.events[0]?.envelopeId ?? "", + ); + expect(raw.exitCode, raw.stderr).toBe(0); + const envelope = JSON.parse(raw.stdout) as { data: string }; + expect(Buffer.from(envelope.data, "base64").toString()).toContain( + "web event", + ); + + const stream = await fetch(web.streamUrl, { + signal: AbortSignal.timeout(5000), + }); + const reader = stream.body?.getReader(); + expect(reader).toBeDefined(); + const first = await reader?.read(); + expect(new TextDecoder().decode(first?.value)).toContain("web event"); + await reader?.cancel(); + const resetUrl = new URL(web.streamUrl); + resetUrl.pathname = resetUrl.pathname.replace(/\/stream$/, "/ui/reset"); + expect( + ( + await fetch(resetUrl, { + method: "POST", + headers: { Origin: "https://example.com" }, + }) + ).status, + ).toBe(403); + expect( + ( + await fetch(resetUrl, { + method: "POST", + headers: { Origin: "http://localhost:5173" }, + }) + ).status, + ).toBe(204); + expect(JSON.parse((await cli("event", "list", "web")).stdout)).toEqual({ + events: [], + }); + expect((await cli("event", "list", "worker")).stdout).toContain( + "b".repeat(32), + ); + const closed = await cli("session", "close", worker.id); + expect(closed.exitCode, closed.stderr).toBe(0); + expect(JSON.parse(closed.stdout)).toMatchObject({ + id: worker.id, + state: "retained", + }); + expect( + (await cli("event", "view", worker.id, "b".repeat(32))).stdout, + ).toContain("worker event"); + }, 30_000); + + test("configures retention and grows a live session without losing history or replaying duplicates", async () => { + const session = await createSession("limits", await availablePort(), 2); + await ingest(session, "one", "one"); + await ingest(session, "two", "two"); + const before = JSON.parse( + (await cli("event", "list", session.id)).stdout, + ).events; + const streamUrl = new URL(session.streamUrl); + streamUrl.searchParams.set("retention", "1"); + const response = await fetch(streamUrl, { + signal: AbortSignal.timeout(30000), + }); + const reader = response.body!.getReader(); + let text = ""; + async function until(marker: string) { + while (!text.includes(marker)) { + const chunk = await reader.read(); + if (chunk.done) throw new Error("Stream closed"); + text += new TextDecoder().decode(chunk.value); + } + } + try { + await until('"message":"two"'); + const grown = await cli( + "session", + "retention", + session.id, + "--max-envelopes", + "4", + ); + expect(grown.exitCode, grown.stderr).toBe(0); + expect(JSON.parse(grown.stdout)).toMatchObject({ + maxEnvelopes: 4, + retainedEnvelopes: 2, + }); + await until('"maxEnvelopes":4'); + await ingest(session, "three", "three"); + await ingest(session, "four", "four"); + await until('"message":"four"'); + expect(text.match(/"message":"one"/g)).toHaveLength(1); + const after = JSON.parse( + (await cli("event", "list", session.id)).stdout, + ).events; + expect(after).toHaveLength(4); + for (const event of before) expect(after).toContainEqual(event); + const rejected = await cli( + "session", + "retention", + session.id, + "--max-envelopes", + "3", + ); + expect(rejected.exitCode).not.toBe(0); + expect(rejected.stderr).toContain("4 retained envelopes"); + const uiUrl = new URL(session.streamUrl); + uiUrl.pathname = uiUrl.pathname.replace(/stream$/, "ui/retention"); + const updated = await fetch(uiUrl, { + method: "POST", + headers: { + Origin: "http://localhost:5173", + "Content-Type": "application/json", + }, + body: JSON.stringify({ maxEnvelopes: 6 }), + }); + expect(updated.status).toBe(200); + await until('"maxEnvelopes":6'); + expect( + JSON.parse((await cli("event", "list", session.id)).stdout).events, + ).toHaveLength(4); + const reset = new URL(session.streamUrl); + reset.pathname = reset.pathname.replace(/stream$/, "ui/reset"); + expect((await fetch(reset, { method: "POST" })).status).toBe(204); + expect( + (await cli("session", "retention", session.id, "--max-envelopes", "1")) + .exitCode, + ).toBe(0); + await ingest(session, "five", "five"); + await ingest(session, "six", "six"); + expect( + JSON.parse((await cli("event", "list", session.id)).stdout).events, + ).toMatchObject([{ eventId: "six" }]); + } finally { + await reader.cancel(); + } + for (const value of ["0", "1.5", "50001"]) { + const invalid = await cli( + "session", + "create", + "invalid", + "--max-envelopes", + value, + ); + expect(invalid.exitCode).not.toBe(0); + expect(invalid.stderr).toContain("Retention limit must be an integer"); + } + }, 30_000); + + test("queries SDK event IDs from item headers and payloads", async () => { + const session = await createSession("sdk IDs", await availablePort()); + for (const [itemHeader, payload] of [ + [{ type: "event", event_id: "item-event" }, { message: "item ID event" }], + [ + { type: "transaction" }, + { event_id: "payload-event", transaction: "payload ID transaction" }, + ], + ]) { + const response = await fetch(session.ingestUrl, { + method: "POST", + headers: { "Content-Type": "application/x-sentry-envelope" }, + body: [{ sdk: { name: "sentry.node" } }, itemHeader, payload] + .map((value) => JSON.stringify(value)) + .join("\n"), + }); + expect(response.status).toBe(204); + } + const listed = await cli("event", "list", session.id); + expect(listed.exitCode, listed.stderr).toBe(0); + expect( + JSON.parse(listed.stdout) + .events.map((event: { eventId: string }) => event.eventId) + .sort(), + ).toEqual(["item-event", "payload-event"]); + for (const eventId of ["item-event", "payload-event"]) { + const viewed = await cli("event", "view", session.id, eventId); + expect(viewed.exitCode, viewed.stderr).toBe(0); + expect(JSON.parse(viewed.stdout).eventId).toBe(eventId); + } + }, 30_000); + + test("views telemetry using quoted and split multi-word session names", async () => { + const session = await createSession("My App", await availablePort()); + const eventId = "d".repeat(32); + await ingest(session, eventId, "multi-word session event"); + const listed = await cli("event", "list", "My", "App"); + expect(listed.exitCode, listed.stderr).toBe(0); + const { events } = JSON.parse(listed.stdout) as { + events: { envelopeId: string }[]; + }; + const envelopeId = events[0]?.envelopeId; + expect(envelopeId).toBeDefined(); + for (const parts of [["My App"], ["My", "App"]]) { + const event = await cli("event", "view", ...parts, eventId); + expect(event.exitCode, event.stderr).toBe(0); + expect(event.stdout).toContain("multi-word session event"); + const raw = await cli("envelope", "view", ...parts, envelopeId!); + expect(raw.exitCode, raw.stderr).toBe(0); + const envelope = JSON.parse(raw.stdout) as { data: string }; + expect(Buffer.from(envelope.data, "base64").toString()).toContain( + "multi-word session event", + ); + } + for (const command of ["event", "envelope"]) { + const missingId = await cli(command, "view", session.id); + expect(missingId.exitCode).not.toBe(0); + expect(missingId.stderr).toContain(`Provide a session and ${command} ID`); + } + }, 30_000); + + test("retains a wrapped application's session and injects scoped SDK URLs", async () => { + const port = await availablePort(); + const script = join(getConfigDir(), "app.cjs"); + const { writeFile } = await import("node:fs/promises"); + await writeFile( + script, + `const url = process.env.SENTRY_SPOTLIGHT; + if (url !== process.env.VITE_SENTRY_SPOTLIGHT) process.exit(2); + fetch(url, {method:'POST', headers:{'Content-Type':'application/x-sentry-envelope'}, + body:'{"event_id":"${"c".repeat(32)}"}\\n{"type":"event"}\\n{"message":"wrapped app"}'}) + .then(r => {if (r.status !== 204) process.exitCode=3; console.error('app completed');}) + .catch(() => {process.exitCode=4;});`, + ); + const result = await runCli( + [ + "local", + "run", + "--session", + "wrapped", + "--max-envelopes", + "1000", + "--host", + "127.0.0.1", + "--port", + String(port), + "--format", + "json", + "--", + process.execPath, + script, + ], + { + env: { + SENTRY_CONFIG_DIR: getConfigDir(), + SENTRY_CLI_NO_TELEMETRY: "1", + SENTRY_AUTH_TOKEN: "", + SENTRY_TOKEN: "", + }, + }, + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stderr).toContain("app completed"); + expect(result.stdout).toContain("wrapped app"); + const listed = await cli("session", "list"); + expect(JSON.parse(listed.stdout)).toMatchObject({ + sessions: [{ label: "wrapped", state: "retained", maxEnvelopes: 1000 }], + }); + expect( + (await cli("event", "view", "wrapped", "c".repeat(32))).stdout, + ).toContain("wrapped app"); + }, 30_000); + + test("captures telemetry from the real Sentry Node SDK through the injected URL", async () => { + const port = await availablePort(); + const result = await runCli( + [ + "local", + "run", + "--session", + "sdk", + "--host", + "127.0.0.1", + "--port", + String(port), + "--format", + "json", + "--", + process.execPath, + "--input-type=module", + "-e", + `import * as Sentry from '@sentry/node'; + Sentry.init({enableLogs: true, tracesSampleRate: 1}); + const id = Sentry.captureException(new Error('Local real SDK event')); + console.error('SDK_EVENT:' + id); + await Sentry.flush(2000); + await new Promise(resolve => setTimeout(resolve, 500)); + await Sentry.close(1000);`, + ], + { + env: { + SENTRY_CONFIG_DIR: getConfigDir(), + SENTRY_CLI_NO_TELEMETRY: "1", + SENTRY_AUTH_TOKEN: "", + SENTRY_TOKEN: "", + SENTRY_DSN: "", + }, + }, + ); + expect(result.exitCode, result.stderr).toBe(0); + const eventId = result.stderr.match(/SDK_EVENT:([a-f0-9]{32})/)?.[1]; + expect(eventId, result.stderr).toBeDefined(); + expect(result.stdout).toContain("Local real SDK event"); + const session = JSON.parse( + (await cli("session", "view", "sdk")).stdout, + ) as Session; + expect(result.stderr).toContain("configured for session"); + expect(result.stderr).toContain("session view"); + expect(result.stderr).not.toContain( + new URL(session.ingestUrl).pathname.split("/").at(-1), + ); + expect((await cli("event", "list", "sdk")).stdout).toContain(eventId); + expect((await cli("event", "view", "sdk", eventId ?? "")).stdout).toContain( + "Local real SDK event", + ); + }, 30_000); + + test("recovers an expired incomplete owner and preserves live singleton ownership", async () => { + const owner = join(getConfigDir(), "local-control-plane.owner"); + await writeFile(owner, ""); + const expired = new Date(Date.now() - 6000); + await utimes(owner, expired, expired); + const first = await createSession("reclaimed-owner", await availablePort()); + const pid = await readFile(owner, "utf8"); + expect(Number(pid)).toBeGreaterThan(0); + await createSession("same-owner", await availablePort()); + expect(await readFile(owner, "utf8")).toBe(pid); + await ingest(first, "owner-recovery", "recovered receiver"); + expect( + JSON.parse((await cli("event", "list", first.id)).stdout), + ).toMatchObject({ + events: [{ eventId: "owner-recovery" }], + }); + expect( + (await cli("event", "view", first.id, "owner-recovery")).stdout, + ).toContain("recovered receiver"); + await cli("daemon", "stop"); + await expect(readFile(owner, "utf8")).rejects.toMatchObject({ + code: "ENOENT", + }); + }, 30000); + + test("attaches to a foreground daemon that owns startup before discovery is published", async () => { + const { spawn } = await import("node:child_process"); + const { getCliCommand } = await import("../fixture.js"); + const reserve = createServer(); + await new Promise((resolve) => + reserve.listen(0, "127.0.0.1", resolve), + ); + const address = reserve.address(); + if (!address || typeof address === "string") + throw new Error("Expected TCP address"); + const [executable, ...prefix] = getCliCommand(); + if (!executable) throw new Error("Missing CLI executable"); + const foreground = spawn( + executable, + [ + ...prefix, + "local", + "daemon", + "start", + "--foreground", + "--host", + "127.0.0.1", + "--port", + String(address.port), + ], + { + cwd: join(import.meta.dirname, "../.."), + env: { + ...process.env, + SENTRY_CONFIG_DIR: getConfigDir(), + SENTRY_CLI_NO_TELEMETRY: "1", + SENTRY_AUTH_TOKEN: "", + SENTRY_TOKEN: "", + }, + stdio: ["ignore", "ignore", "pipe"], + }, + ); + let stderr = ""; + foreground.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + const closed = new Promise((resolve, reject) => { + foreground.once("close", () => resolve()); + foreground.once("error", reject); + }); + let pending: ReturnType | undefined; + try { + const owner = join(getConfigDir(), "local-control-plane.owner"); + await expect + .poll( + async () => Number(await readFile(owner, "utf8").catch(() => "0")), + { timeout: 10000 }, + ) + .toBe(foreground.pid); + await expect( + readFile(join(getConfigDir(), "local-control-plane.json"), "utf8"), + ).rejects.toMatchObject({ code: "ENOENT" }); + pending = cli( + "session", + "create", + "foreground-race", + "--host", + "127.0.0.1", + "--port", + String(address.port), + ); + // Keep foreground startup inside the real listener retry window while the client joins it. + await sleep(6500); + await new Promise((resolve) => reserve.close(() => resolve())); + const result = await pending; + expect(result.exitCode, result.stderr).toBe(0); + const session = JSON.parse(result.stdout) as Session; + const state = JSON.parse( + await readFile( + join(getConfigDir(), "local-control-plane.json"), + "utf8", + ), + ) as { pid: number }; + expect(state.pid, stderr).toBe(foreground.pid); + await ingest(session, "foreground-owner", "foreground capture"); + expect( + (await cli("event", "view", session.id, "foreground-owner")).stdout, + ).toContain("foreground capture"); + } finally { + reserve.close(); + await cli("daemon", "stop"); + foreground.kill("SIGTERM"); + await closed; + await pending; + } + }, 30000); + + test("waits for the port retry window during concurrent daemon startup", async () => { + const server = createServer(); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + const address = server.address(); + if (!address || typeof address === "string") + throw new Error("Expected a TCP address"); + const pending = Promise.allSettled([ + createSession("retry-a", address.port), + createSession("retry-b", address.port), + ]); + try { + await sleep(6500); + await new Promise((resolve) => server.close(() => resolve())); + const results = await pending; + if (results.some((result) => result.status === "rejected")) + await sleep(5000); + expect(results.map((result) => result.status)).toEqual([ + "fulfilled", + "fulfilled", + ]); + expect( + JSON.parse((await cli("session", "list")).stdout).sessions, + ).toHaveLength(2); + } finally { + server.close(); + } + }, 30_000); + + test("reports a failed daemon startup without leaving an owner or late listener", async () => { + const server = createServer(); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + const address = server.address(); + if (!address || typeof address === "string") + throw new Error("Expected a TCP address"); + try { + const result = await cli( + "daemon", + "start", + "--host", + "127.0.0.1", + "--port", + String(address.port), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("exited before startup"); + for (const filename of [ + "local-control-plane.owner", + "local-control-plane.lock", + ]) { + await expect( + readFile(join(getConfigDir(), filename), "utf8"), + ).rejects.toMatchObject({ code: "ENOENT" }); + } + await new Promise((resolve) => server.close(() => resolve())); + await sleep(1000); + expect(JSON.parse((await cli("daemon", "status")).stdout)).toEqual({ + daemon: null, + }); + } finally { + server.close(); + } + }, 30_000); + + test("rejects legacy commands before running an app against the managed daemon", async () => { + const port = await availablePort(); + await createSession("managed", port); + const result = await runCli( + [ + "local", + "run", + "--host", + "127.0.0.1", + "--port", + String(port), + "--", + process.execPath, + "-e", + "console.error('LEGACY_CHILD_RAN')", + ], + { + env: { + SENTRY_CONFIG_DIR: getConfigDir(), + SENTRY_CLI_NO_TELEMETRY: "1", + }, + }, + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("--session"); + expect(result.stderr).not.toContain("LEGACY_CHILD_RAN"); + const serve = await runCli( + ["local", "serve", "--host", "127.0.0.1", "--port", String(port)], + { + env: { + SENTRY_CONFIG_DIR: getConfigDir(), + SENTRY_CLI_NO_TELEMETRY: "1", + SENTRY_AUTH_TOKEN: "", + SENTRY_TOKEN: "", + }, + }, + ); + expect(serve.exitCode).not.toBe(0); + expect(serve.stderr).toContain("--session"); + }, 30_000); + + test("restarts on the configured port and preserves singleton discovery", async () => { + const port = await availablePort(); + await createSession("restart", port); + const result = await cli("daemon", "restart"); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + host: "127.0.0.1", + port, + }); + await sleep(100); + expect((await cli("daemon", "status")).exitCode).toBe(0); + }, 30_000); +}); + +describe("Local foreground commands", () => { + test("rejects verification with a retained session before running the application", async () => { + const marker = join(getConfigDir(), "verify-session-child-ran"); + const script = join(getConfigDir(), "verify-session-child.cjs"); + await writeFile( + script, + `require("node:fs").writeFileSync(${JSON.stringify(marker)}, "ran");`, + ); + const result = await runCli( + [ + "local", + "run", + "--session", + "verification", + "--verify", + "--", + process.execPath, + script, + ], + { + env: { + SENTRY_CONFIG_DIR: getConfigDir(), + SENTRY_CLI_NO_TELEMETRY: "1", + SENTRY_AUTH_TOKEN: "", + SENTRY_TOKEN: "", + }, + }, + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("--session cannot be used with --verify."); + await expect(readFile(marker, "utf8")).rejects.toMatchObject({ + code: "ENOENT", + }); + expect(JSON.parse((await cli("daemon", "status")).stdout)).toEqual({ + daemon: null, + }); + }); + + test("run configures a plain receiver and explicit attachment preserves retained envelopes", async () => { + const { createLocalRetentionBuffer } = + await import("../../src/commands/local/retention-buffer.js"); + const { buildApp, tryListen } = + await import("../../src/commands/local/server.js"); + const retention = createLocalRetentionBuffer(2); + const receiver = await tryListen( + buildApp(retention.buffer, { uiActions: true, retention }), + 0, + "127.0.0.1", + ); + const script = join(getConfigDir(), "legacy-app.cjs"); + await writeFile( + script, + `fetch(new URL('/capabilities',process.env.SENTRY_SPOTLIGHT)).then(r=>r.json()).then(settings=>{console.log('limit='+settings.maxEnvelopes);if(settings.maxEnvelopes!==1000)process.exitCode=2;});`, + ); + try { + await fetch(`http://127.0.0.1:${receiver.port}/stream`, { + method: "POST", + headers: { "Content-Type": "application/x-sentry-envelope" }, + body: '{}\n{"type":"event"}\n{"message":"preserved"}', + }); + const before = retention.buffer.read({ all: true }); + for (const explicit of [true, false]) { + const result = await runCli( + [ + "local", + "run", + "--host", + "127.0.0.1", + "--port", + String(receiver.port), + ...(explicit ? ["--max-envelopes", "1000"] : []), + "--", + process.execPath, + script, + ], + { + env: { + SENTRY_CONFIG_DIR: getConfigDir(), + SENTRY_CLI_NO_TELEMETRY: "1", + }, + }, + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain("limit=1000"); + expect(retention.buffer.read({ all: true })).toEqual(before); + } + const created = await runCli( + [ + "local", + "run", + "--host", + "127.0.0.1", + "--port", + String(await availablePort()), + "--max-envelopes", + "1000", + "--", + process.execPath, + script, + ], + { + env: { + SENTRY_CONFIG_DIR: getConfigDir(), + SENTRY_CLI_NO_TELEMETRY: "1", + }, + }, + ); + expect(created.exitCode, created.stderr).toBe(0); + expect(created.stdout).toContain("limit=1000"); + } finally { + receiver.server.closeAllConnections(); + await new Promise((resolve) => + receiver.server.close(() => resolve()), + ); + } + }, 30000); + + test("serve consumes the scoped stream and a second foreground daemon cannot replace discovery", async () => { + const { spawn } = await import("node:child_process"); + const { getCliCommand } = await import("../fixture.js"); + const port = await availablePort(); + const session = await createSession("existing", port); + const before = await readFile( + join(getConfigDir(), "local-control-plane.json"), + "utf8", + ); + const rejected = await cli( + "daemon", + "start", + "--foreground", + "--host", + "127.0.0.1", + "--port", + String(await availablePort()), + ); + expect(rejected.exitCode).not.toBe(0); + expect( + await readFile(join(getConfigDir(), "local-control-plane.json"), "utf8"), + ).toBe(before); + const [executable, ...prefix] = getCliCommand(); + if (!executable) throw new Error("Missing CLI executable"); + const child = spawn( + executable, + [ + ...prefix, + "local", + "serve", + "--session", + "serve-e2e", + "--max-envelopes", + "1500", + "--format", + "json", + ], + { + cwd: join(import.meta.dirname, "../.."), + env: { + ...process.env, + SENTRY_CONFIG_DIR: getConfigDir(), + SENTRY_CLI_NO_TELEMETRY: "1", + SENTRY_AUTH_TOKEN: "", + SENTRY_TOKEN: "", + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + const closed = new Promise((resolve, reject) => { + child.once("close", () => resolve()); + child.once("error", reject); + }); + try { + await expect + .poll( + async () => { + const listed = JSON.parse( + (await cli("session", "list")).stdout, + ) as { sessions: { label: string; id: string }[] }; + return listed.sessions.find((item) => item.label === "serve-e2e") + ?.id; + }, + { timeout: 10000 }, + ) + .toBeDefined(); + const listed = JSON.parse((await cli("session", "list")).stdout) as { + sessions: { label: string; id: string }[]; + }; + const id = listed.sessions.find((item) => item.label === "serve-e2e")?.id; + if (!id) throw new Error("Missing serve session"); + const scoped = JSON.parse( + (await cli("session", "view", id)).stdout, + ) as Session; + expect( + JSON.parse((await cli("session", "retention", id)).stdout).maxEnvelopes, + ).toBe(1500); + await ingest(scoped, "e".repeat(32), "serve session"); + await expect + .poll(() => stdout, { timeout: 5000 }) + .toContain("serve session"); + await ingest(session, "d".repeat(32), "other session"); + expect(stdout).not.toContain("other session"); + expect(child.exitCode, stderr).toBeNull(); + await cli("session", "close", id); + } finally { + child.kill("SIGTERM"); + await closed; + } + }, 30000); +}); diff --git a/packages/cli/test/fixture.ts b/packages/cli/test/fixture.ts index 669fa5720..1614963e3 100644 --- a/packages/cli/test/fixture.ts +++ b/packages/cli/test/fixture.ts @@ -58,6 +58,9 @@ export type CliResult = { export function getCliCommand(): string[] { const binaryPath = process.env.SENTRY_CLI_BINARY; if (binaryPath) { + if (/\.[cm]?js$/i.test(binaryPath)) { + return [process.execPath, binaryPath]; + } return [binaryPath]; } return [ diff --git a/packages/cli/test/lib/credential-redaction.test.ts b/packages/cli/test/lib/credential-redaction.test.ts index 5730646e3..23d992986 100644 --- a/packages/cli/test/lib/credential-redaction.test.ts +++ b/packages/cli/test/lib/credential-redaction.test.ts @@ -4,6 +4,44 @@ import { ApiError, formatError, getExitCode } from "../../src/lib/errors.js"; import { SentryError } from "../../src/lib/sdk-types.js"; describe("credential redaction", () => { + test.each([ + ["/ingest/SYNTHETIC_ingest-cap", "/ingest/[REDACTED]"], + [ + "/stream?cap=SYNTHETIC_read-cap&base64=true", + "/stream?cap=[REDACTED]&base64=true", + ], + [ + "/stream?base64=true&cap=SYNTHETIC_read-cap", + "/stream?base64=true&cap=[REDACTED]", + ], + [ + "/stream?cap=SYNTHETIC_one&cap=SYNTHETIC_two", + "/stream?cap=[REDACTED]&cap=[REDACTED]", + ], + ["/ui/reset?cap=SYNTHETIC_read-cap", "/ui/reset?cap=[REDACTED]"], + ["/ui/retention?cap=SYNTHETIC_read-cap", "/ui/retention?cap=[REDACTED]"], + ["/ui/close?cap=SYNTHETIC_read-cap", "/ui/close?cap=[REDACTED]"], + ])( + "redacts Local URL capabilities in diagnostics: %s", + (suffix, safeSuffix) => { + const path = "/_local/v1/sessions/session-id"; + for (const origin of ["", "http://127.0.0.1:8969", "http://[::1]:8969"]) { + const message = `POST ${origin}${path}${suffix}`; + const expected = `POST ${origin}${path}${safeSuffix}`; + expect(redactCredentialText(message)).toBe(expected); + expect( + JSON.parse(redactCredentialText(JSON.stringify({ message }))), + ).toEqual({ message: expected }); + } + }, + ); + + test("preserves URLs outside the Local capability routes", () => { + const message = + "https://example.com/stream?cap=public /_local/v1/sessions/web /_local/v1/sessions/web/ingest/:cap"; + expect(redactCredentialText(message)).toBe(message); + }); + test.each([ "sntrys_SYNTHETIC_PAYLOAD\n_SYNTHETIC_SECRET", "sntryu_SYNTHETIC_PAYLOAD\r\n_SYNTHETIC_SECRET", diff --git a/packages/cli/test/lib/telemetry-credentials.test.ts b/packages/cli/test/lib/telemetry-credentials.test.ts index 27f3d9423..1c69dc872 100644 --- a/packages/cli/test/lib/telemetry-credentials.test.ts +++ b/packages/cli/test/lib/telemetry-credentials.test.ts @@ -159,6 +159,38 @@ describe("telemetry credential boundaries", () => { expect(serialized).not.toContain("SYNTHETIC"); }); + test("removes Local capabilities from HTTP telemetry without changing caller URLs", async () => { + const capture = captureTelemetry(); + const ingestUrl = + "http://127.0.0.1:8969/_local/v1/sessions/sdk/ingest/LOCAL_INGEST_SECRET"; + const streamUrl = + "http://127.0.0.1:8969/_local/v1/sessions/sdk/stream?base64=true&cap=LOCAL_READ_SECRET"; + const resetUrl = + "http://127.0.0.1:8969/_local/v1/sessions/sdk/ui/reset?cap=LOCAL_READ_SECRET"; + const event: ErrorEvent = { + message: `Rejected POST ${ingestUrl}`, + request: { url: ingestUrl }, + breadcrumbs: [{ type: "http", data: { url: streamUrl } }], + extra: { resetUrl }, + }; + captureEvent(event); + startSpan({ name: `POST ${ingestUrl}`, forceTransaction: true }, (span) => { + span.setAttribute("http.url", ingestUrl); + span.setAttribute("http.target", new URL(ingestUrl).pathname); + span.setAttribute("stream.url", streamUrl); + }); + await capture.client.flush(1000); + for (const type of ["event", "transaction"]) { + const serialized = capture.serializedEnvelope(type); + expect(serialized).not.toContain("LOCAL_INGEST_SECRET"); + expect(serialized).not.toContain("LOCAL_READ_SECRET"); + expect(serialized).toContain("[REDACTED]"); + expect(serialized).toContain("/_local/v1/sessions/sdk/"); + } + expect(event.request?.url).toBe(ingestUrl); + expect(event.extra?.resetUrl).toBe(resetUrl); + }); + test("redacts before deriving a grouping key from the first line", () => { expect(extractMessagePrefix(headerError, 4)).toBe( "Headers.set: is an invalid", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a54503ee..ac190f8c1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -232,7 +232,7 @@ importers: version: 1.4.2(typescript@5.8.3) vitest: specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.16.0)(@vitest/coverage-v8@4.1.11)(msw@2.10.2(@types/node@22.16.0)(typescript@5.8.3))(vite@8.3.0(@types/node@22.16.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.16.0)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@22.16.0)(typescript@5.8.3))(vite@8.3.0(@types/node@22.16.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0)) vitest-evals: specifier: 'catalog:' version: 0.4.0(tinyrainbow@3.1.0)(vitest@4.1.11) @@ -272,6 +272,109 @@ importers: specifier: ^4.118.0 version: 4.133.0(@types/node@24.13.5) + apps/local: + dependencies: + '@radix-ui/react-dialog': + specifier: ^1.1.23 + version: 1.3.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': + specifier: ^1.3.3 + version: 1.3.3(@types/react@19.3.0)(react@19.2.8) + '@shikijs/core': + specifier: ^3.23.0 + version: 3.23.0 + '@shikijs/engine-javascript': + specifier: ^3.23.0 + version: 3.23.0 + '@shikijs/langs': + specifier: ^3.23.0 + version: 3.23.0 + '@shikijs/themes': + specifier: ^3.23.0 + version: 3.23.0 + '@tailwindcss/vite': + specifier: ^4.3.3 + version: 4.3.3(vite@8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + '@uidotdev/usehooks': + specifier: ^2.4.1 + version: 2.4.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@vercel/analytics': + specifier: ^2.0.1 + version: 2.0.1(react@19.2.8) + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + cmdk: + specifier: ^1.1.1 + version: 1.1.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + lucide-react: + specifier: ^1.43.0 + version: 1.55.0(@types/react@19.3.0)(react@19.2.8) + next-themes: + specifier: ^0.4.6 + version: 0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + nuqs: + specifier: ^2.10.1 + version: 2.10.2(react-router-dom@7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-router@7.18.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) + react: + specifier: ^19.2.8 + version: 19.2.8 + react-dom: + specifier: ^19.2.8 + version: 19.2.8(react@19.2.8) + react-router: + specifier: ^7.18.3 + version: 7.18.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + tailwind-merge: + specifier: ^3.6.0 + version: 3.7.0 + tailwindcss: + specifier: ^4.3.3 + version: 4.3.3 + tslib: + specifier: ^2.8.1 + version: 2.8.1 + devDependencies: + '@spotlightjs/spotlight': + specifier: ^4.11.7 + version: 4.11.8(@cfworker/json-schema@4.1.1)(@opentelemetry/core@2.11.0(@opentelemetry/api@1.9.1))(hono-rate-limiter@0.4.2(hono@4.13.8)) + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@types/node': + specifier: ^24.13.3 + version: 24.13.5 + '@types/react': + specifier: ^19.2.18 + version: 19.3.0 + '@types/react-dom': + specifier: ^19.2.4 + version: 19.3.0(@types/react@19.3.0) + '@vitejs/plugin-react': + specifier: ^6.1.0 + version: 6.1.2(@rolldown/plugin-babel@0.2.3(@babel/core@7.28.0)(@babel/runtime@7.28.4)(rolldown@1.2.9)(vite@8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)))(vite@8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + eventsource: + specifier: ^4.1.0 + version: 4.1.1 + jsdom: + specifier: ^28.1.0 + version: 28.1.0 + oxlint: + specifier: ^1.79.0 + version: 1.87.0 + typescript: + specifier: 'catalog:' + version: 5.8.3 + vite: + specifier: ^8.2.2 + version: 8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0) + vitest: + specifier: ^4.1.9 + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.5)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@24.13.5)(typescript@5.8.3))(vite@8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + packages/cli: devDependencies: '@anthropic-ai/sdk': @@ -441,7 +544,7 @@ importers: version: 1.4.2(typescript@5.9.3) vitest: specifier: ^4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.20.3)(@vitest/coverage-v8@4.1.11)(msw@2.10.2(@types/node@22.20.3)(typescript@5.9.3))(vite@8.3.0(@types/node@22.20.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.20.3)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@22.20.3)(typescript@5.9.3))(vite@8.3.0(@types/node@22.20.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) wrap-ansi: specifier: ^10.0.0 version: 10.0.1 @@ -474,10 +577,10 @@ importers: version: 11.1.0(@cloudflare/workers-types@4.20260405.1)(wrangler@4.80.0(@cloudflare/workers-types@4.20260405.1)(@types/node@24.13.5)) '@sentry/react': specifier: 'catalog:' - version: 11.1.0(react-router@7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) + version: 11.1.0(react-router@7.18.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) agents: specifier: 'catalog:' - version: 0.20.1(@ai-sdk/react@3.0.232(react@19.2.8)(zod@4.4.3))(@babel/core@7.28.0)(@babel/runtime@7.28.4)(@cloudflare/workers-types@4.20260405.1)(@modelcontextprotocol/client@2.0.0)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(@modelcontextprotocol/server@2.0.0)(ai@6.0.64(zod@4.4.3))(chat@4.40.0(ai@6.0.64(zod@4.4.3))(zod@4.4.3))(react@19.2.8)(rolldown@1.0.0-beta.23)(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3))(zod@4.4.3) + version: 0.20.1(@ai-sdk/react@3.0.232(react@19.2.8)(zod@4.4.3))(@babel/core@7.28.0)(@babel/runtime@7.28.4)(@cloudflare/workers-types@4.20260405.1)(@modelcontextprotocol/client@2.0.0)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(@modelcontextprotocol/server@2.0.0)(ai@6.0.64(zod@4.4.3))(chat@4.40.0(ai@6.0.64(zod@4.4.3))(zod@4.4.3))(react@19.2.8)(rolldown@1.0.0-beta.23)(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.33.0)(tsx@4.20.3)(yaml@2.8.3))(zod@4.4.3) ai: specifier: 'catalog:' version: 6.0.64(zod@4.4.3) @@ -532,7 +635,7 @@ importers: devDependencies: '@cloudflare/vite-plugin': specifier: ^1.13.15 - version: 1.13.15(@types/node@24.13.5)(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3))(workerd@1.20260401.1)(wrangler@4.80.0(@cloudflare/workers-types@4.20260405.1)(@types/node@24.13.5)) + version: 1.13.15(@types/node@24.13.5)(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.33.0)(tsx@4.20.3)(yaml@2.8.3))(workerd@1.20260401.1)(wrangler@4.80.0(@cloudflare/workers-types@4.20260405.1)(@types/node@24.13.5)) '@cloudflare/vitest-pool-workers': specifier: 'catalog:' version: 0.14.1(@cloudflare/workers-types@4.20260405.1)(@types/node@24.13.5)(@vitest/runner@4.1.11)(@vitest/snapshot@4.1.11)(vitest@4.1.11) @@ -556,7 +659,7 @@ importers: version: 0.5.16(tailwindcss@4.3.3) '@tailwindcss/vite': specifier: 'catalog:' - version: 4.3.3(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3)) + version: 4.3.3(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.33.0)(tsx@4.20.3)(yaml@2.8.3)) '@types/react': specifier: 'catalog:' version: 19.1.8 @@ -568,7 +671,7 @@ importers: version: 4.2.5 '@vitejs/plugin-react': specifier: 'catalog:' - version: 4.6.0(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3)) + version: 4.6.0(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.33.0)(tsx@4.20.3)(yaml@2.8.3)) miniflare: specifier: 'catalog:' version: 4.20260401.0(@types/node@24.13.5) @@ -580,10 +683,10 @@ importers: version: 10.1.0 vite: specifier: 'catalog:' - version: 6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3) + version: 6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.33.0)(tsx@4.20.3)(yaml@2.8.3) vitest: specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.5)(@vitest/coverage-v8@4.1.11)(msw@2.10.2(@types/node@24.13.5)(typescript@5.9.3))(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.5)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@24.13.5)(typescript@5.9.3))(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.33.0)(tsx@4.20.3)(yaml@2.8.3)) wrangler: specifier: 4.80.0 version: 4.80.0(@cloudflare/workers-types@4.20260405.1)(@types/node@24.13.5) @@ -702,7 +805,7 @@ importers: version: 5.8.3 vitest: specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.16.0)(@vitest/coverage-v8@4.1.11)(msw@2.10.2(@types/node@22.16.0)(typescript@5.8.3))(vite@8.3.0(@types/node@22.16.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.16.0)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@22.16.0)(typescript@5.8.3))(vite@8.3.0(@types/node@22.16.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0)) packages/mcp-server-evals: dependencies: @@ -741,7 +844,7 @@ importers: version: 5.8.3 vitest: specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.5)(@vitest/coverage-v8@4.1.11)(msw@2.10.2(@types/node@24.13.5)(typescript@5.8.3))(vite@8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.5)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@24.13.5)(typescript@5.8.3))(vite@8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) vitest-evals: specifier: 'catalog:' version: 0.4.0(tinyrainbow@3.1.0)(vitest@4.1.11) @@ -814,7 +917,7 @@ importers: version: 5.8.3 vitest: specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.5)(@vitest/coverage-v8@4.1.11)(msw@2.10.2(@types/node@24.13.5)(typescript@5.8.3))(vite@8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.5)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@24.13.5)(typescript@5.8.3))(vite@8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0)) packages/smoke-tests: devDependencies: @@ -823,7 +926,7 @@ importers: version: 22.16.0 vitest: specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.16.0)(@vitest/coverage-v8@4.1.11)(msw@2.10.2(@types/node@22.16.0)(typescript@5.9.3))(vite@8.3.0(@types/node@22.16.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.16.0)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@22.16.0)(typescript@5.9.3))(vite@8.3.0(@types/node@22.16.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) packages/toolkit-core: devDependencies: @@ -832,7 +935,7 @@ importers: version: 5.8.3 vitest: specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.5)(@vitest/coverage-v8@4.1.11)(msw@2.10.2(@types/node@24.13.5)(typescript@5.8.3))(vite@8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.5)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@24.13.5)(typescript@5.8.3))(vite@8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) packages: @@ -866,6 +969,9 @@ packages: express: optional: true + '@acemir/cssom@0.9.32': + resolution: {integrity: sha512-OijrAUu7rrJhSIoHNg6LMPS5e344nwUHA+1EFYYjrh64D+GmytUsfBQjci45TjT+rd5Spsp8B5xsZDT+TgyXXQ==} + '@ai-sdk/anthropic@3.0.33': resolution: {integrity: sha512-MvQzipOAK99Ct3nX6d8oz2AJ9c8kbiU2uSpGYK3F5oBBa8Gy04+LbYBoB9oH8bbZJxFafkgwBq1+k8xpU7vVxQ==} engines: {node: '>=18'} @@ -974,6 +1080,20 @@ packages: '@apm-js-collab/tracing-hooks@0.10.1': resolution: {integrity: sha512-w2OWXR7FWrKqSziuE9+QclaZrStxO/8+OwbXM635s/zs0Eez1Qo3ivSPdB2WsaPY/iznKTytONPx/PitD7IXcA==} + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@6.8.1': + resolution: {integrity: sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@ast-grep/cli-darwin-arm64@0.43.0': resolution: {integrity: sha512-0i63gSBbgriPaRpFsbP3yETxolHPK2JAZbpcGbFOytB7QTnKAguwhlKmIOkUGKfsCzYiEq1awY0EBmvjMONXOg==} engines: {node: '>= 10'} @@ -1338,6 +1458,10 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@bramus/specificity@2.4.3': + resolution: {integrity: sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==} + hasBin: true + '@bruits/satteri-darwin-arm64@0.10.5': resolution: {integrity: sha512-27KTVl4TJkVahMy/ohyA7qd4938G5UNneFUz/PsScYfpIhj0IVAS23mpcJXdPF44sa6nva198lmV/cKIb2YPyA==} cpu: [arm64] @@ -1515,6 +1639,7 @@ packages: '@cloudflare/vitest-pool-workers@0.14.1': resolution: {integrity: sha512-AuIvoGXi9bMjgD1636JtP84yGsjhdtsk23rYMVFLO9KB84rvwhVPkvwgBv74RbhbURXYDKWV5koPwPucFSFgUg==} + deprecated: '@cloudflare/vitest-pool-workers has been renamed to @cloudflare/vitest-plugin. This package will not receive future updates.' peerDependencies: '@vitest/runner': ^4.1.0 '@vitest/snapshot': ^4.1.0 @@ -1620,6 +1745,42 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@csstools/color-helpers@6.1.2': + resolution: {integrity: sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.4.3': + resolution: {integrity: sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.2 + '@csstools/css-tokenizer': ^4.0.2 + + '@csstools/css-color-parser@4.2.6': + resolution: {integrity: sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.2 + '@csstools/css-tokenizer': ^4.0.2 + + '@csstools/css-parser-algorithms@4.0.2': + resolution: {integrity: sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.2 + + '@csstools/css-syntax-patches-for-csstree@1.1.15': + resolution: {integrity: sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.2': + resolution: {integrity: sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==} + engines: {node: '>=20.19.0'} + '@ctrl/tinycolor@4.2.1': resolution: {integrity: sha512-Vh5uy9Y1JBMId6J1f68TMXnZrTCvNdo9Fu2k0d1YnJngR+sM5kWTKl441c/3TFs+cG0NWOjRWgQ/iaWhbYx+7A==} engines: {node: '>=14'} @@ -2295,6 +2456,15 @@ packages: cpu: [x64] os: [win32] + '@exodus/bytes@1.16.0': + resolution: {integrity: sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@expressive-code/core@0.44.2': resolution: {integrity: sha512-Yw5KonQCD1HoOO2P89y4wifqS9qrJcGVY0LkVMUsVZjhi++bhz4WwQ37inenKFNOEVo50P7cjoVsCdaVnUbq8w==} @@ -2560,9 +2730,6 @@ packages: '@jridgewell/sourcemap-codec@1.6.0': resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} - '@jridgewell/trace-mapping@0.3.29': - resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==} - '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} @@ -3170,6 +3337,9 @@ packages: '@radix-ui/primitive@1.1.7': resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==} + '@radix-ui/primitive@1.2.0': + resolution: {integrity: sha512-KABY+VMIRPJL+Vyzv/335FEhXXD/xDv8BMb1kl48/aXJZMH0LtHCTx5ga5RnxaEP3nZHtmTLf1P9vanmFX46bw==} + '@radix-ui/react-accordion@1.2.20': resolution: {integrity: sha512-jDhG9FvAEnlhnjrsINbNXcUa4G+L1KqSkJSunkbKEzFRcAb52jvM0PjPxPRvhe1HNc5F5yc0yzzWeeqlH4yBIg==} peerDependencies: @@ -3236,6 +3406,28 @@ packages: '@types/react': optional: true + '@radix-ui/react-context@1.3.0': + resolution: {integrity: sha512-v5AYEocMISkbJ+1fTXzZpiFv7NRr3D2WiYbLIKf+FkmJ8VkP6d503eV2n34MFussHXY1pcoq3yfJiQH77dc4Mg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.3.0': + resolution: {integrity: sha512-bQUKIf2+/vGpJFCmX2pkxnQVT2Od3gkGGURs+bWCIdQ5JbzyTsItcDL25Y5nlwn2T+m/6utZJL+sEs9g7NfR/Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-direction@1.1.4': resolution: {integrity: sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==} peerDependencies: @@ -3245,6 +3437,41 @@ packages: '@types/react': optional: true + '@radix-ui/react-dismissable-layer@1.2.0': + resolution: {integrity: sha512-oO7RhkUS9cwOjK4DGOWU19g9mk1h9h0ErEcNlVg832rgpBq8PGtQn7xyUJnuIO6EEhw6AbXx6/CGfqNEG0LW0Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.2.0': + resolution: {integrity: sha512-MPm5R0iRGUDrDnPNxmLBkv+X7pN4XKzt+MbWJEuJQsH3NFH4omrbpXpucyYCCKTSt+na/ZxGhEWQrWTZ0285cA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.3.0': + resolution: {integrity: sha512-kWTz59QlCFoE5Q05juX/HljYia4IaKAj5Hdx+WWgEznSVUzw4qZrpRFmV6xvqd9xhqWAFVJ4zQI/Gr/QDblc7w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-id@1.1.4': resolution: {integrity: sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==} peerDependencies: @@ -3254,6 +3481,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-portal@1.2.0': + resolution: {integrity: sha512-StX7xXnaRU41VvFtMm9zlhVGz8XF6GmWWTXftkQaY+tqVkbs/R9UmAp5mLzHHapk5hM9RMZQvO/3usyIP/g42g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-presence@1.1.10': resolution: {integrity: sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==} peerDependencies: @@ -3267,6 +3507,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-presence@1.1.11': + resolution: {integrity: sha512-Z7/atlkab36Z8hm+V8zYc79r0W+tiOB1o0tun1By94JyadkekLQmtEAnBSP9XKXBNQsStoStS4+gX10JpS5akA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-primitive@2.1.10': resolution: {integrity: sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==} peerDependencies: @@ -3280,6 +3533,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-primitive@2.2.0': + resolution: {integrity: sha512-HCTSPMNqv55Qk0IX4geJbpyXazmqulcimwuyjxTkKi29v+QZbG2Zho9BnbBq4YS2PF+KlNb2CXoLz66uUmblkg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-slot@1.2.3': resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} peerDependencies: @@ -3298,6 +3564,24 @@ packages: '@types/react': optional: true + '@radix-ui/react-slot@1.5.0': + resolution: {integrity: sha512-tyYd9ddPXAysAbdbD9eYxgwpCSv6f3PncqVSNKQFSR5lcJVLz3cb/pANDoYrLiHBpXv7skortyUgGUWsYuPPFQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.5': + resolution: {integrity: sha512-Y5GBf4NKeADoXI1geiMlMuMoURT3k9MizzzKhGb/m55H94aGigZ01PIpkvsO6lML13BA6XYAXSJ6gza7vTn5nw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-controllable-state@1.2.6': resolution: {integrity: sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==} peerDependencies: @@ -3307,6 +3591,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-controllable-state@1.3.0': + resolution: {integrity: sha512-TAcBI7bw2ljYCbv4+zuTSd6zICu8pRt6SDN2vJ/Z06K2MXh6I9WYFe/A9ovlFDtR5HVHGm5iDxDoABm/++dbKg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-effect-event@0.0.5': resolution: {integrity: sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==} peerDependencies: @@ -4259,9 +4552,31 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 + '@testing-library/dom@10.4.2': + resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==} + engines: {node: '>=18'} + + '@testing-library/react@16.3.3': + resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -4346,9 +4661,6 @@ packages: '@types/node@22.20.3': resolution: {integrity: sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A==} - '@types/node@24.0.10': - resolution: {integrity: sha512-ENHwaH+JIRTDIEEbDK6QSQntAYGtbvdDXnMXnZaZ6k13Du1dPMmprkEHIL7ok2Wl2aZevetwTAb5S+7yIF+enA==} - '@types/node@24.13.5': resolution: {integrity: sha512-TXyindR+lBr22aJIdMQzCFHPHR6cR4js838mRDCSz5hOKWZvZwsXSSiXDmjRj4iJmgl+sR9O+1mkoVBSMadNug==} @@ -4369,6 +4681,11 @@ packages: peerDependencies: '@types/react': ^19.0.0 + '@types/react-dom@19.3.0': + resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} + peerDependencies: + '@types/react': ^19.3.0 + '@types/react-scroll-to-bottom@4.2.5': resolution: {integrity: sha512-gYMMxxhphzTNfKc4NIkEgu4XRiQjfj/6R7QK10Igz8jOUPNXBLSnK3RS7ofsNWnJDEgpLkNOwSLuASASiGsfHQ==} @@ -4396,6 +4713,13 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@uidotdev/usehooks@2.4.1': + resolution: {integrity: sha512-1I+RwWyS+kdv3Mv0Vmc+p0dPYH0DTRAo04HLyXReYBL9AeseDWUJyi4THuksBJcu9F0Pih69Ak150VDnqbVnXg==} + engines: {node: '>=16'} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} deprecated: Potential CWE-502 - Update to 1.3.1 or higher @@ -4469,6 +4793,22 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0 + '@vitejs/plugin-react@6.1.2': + resolution: {integrity: sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + oxc-transform-react: ^0.152.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + oxc-transform-react: + optional: true + '@vitest/coverage-v8@4.1.11': resolution: {integrity: sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==} peerDependencies: @@ -4549,6 +4889,10 @@ packages: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + agentkeepalive@4.6.0: resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} engines: {node: '>= 8.0.0'} @@ -4644,6 +4988,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -4672,6 +5020,13 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -4794,6 +5149,9 @@ packages: better-sqlite3@11.10.0: resolution: {integrity: sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==} + bidi-js@1.1.0: + resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -5016,6 +5374,12 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cmdk@1.1.1: + resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + code-excerpt@4.0.0: resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -5168,6 +5532,10 @@ packages: resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + cssstyle@6.2.0: + resolution: {integrity: sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==} + engines: {node: '>=20'} + csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} @@ -5178,6 +5546,10 @@ packages: resolution: {integrity: sha512-H4s4arcLx/ugbu1XkkgSvcUZax0L6tXUqnppGniQb8l5VjUKGHoayXE5RiriiPhYDd+kjZnaok1Uig13PKtKYQ==} engines: {node: '>=12.17'} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + debug@4.4.1: resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} engines: {node: '>=6.0'} @@ -5196,6 +5568,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-named-character-reference@1.2.0: resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==} @@ -5244,6 +5619,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + devalue@5.9.2: resolution: {integrity: sha512-po4PAY5c53tw5XMocSnf8A/5OHhbbUftpr93aEN6BBoAdntUmK7vu7wOATqvt7cXO7m1Cl4gMVn6p7n6n4mj0w==} @@ -5262,6 +5640,9 @@ packages: resolution: {integrity: sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA==} hasBin: true + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -5349,6 +5730,10 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + entities@8.1.0: + resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} + engines: {node: '>=20.19.0'} + environment@1.1.0: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} @@ -5708,6 +6093,10 @@ packages: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + get-port@7.1.0: resolution: {integrity: sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==} engines: {node: '>=16'} @@ -5872,6 +6261,10 @@ packages: hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -5894,10 +6287,18 @@ packages: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} engines: {node: '>= 0.8'} + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + https-proxy-agent@5.0.1: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + human-signals@5.0.0: resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} engines: {node: '>=16.17.0'} @@ -6069,6 +6470,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -6157,6 +6561,15 @@ packages: engines: {node: '>=12.0.0'} hasBin: true + jsdom@28.1.0: + resolution: {integrity: sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -6426,6 +6839,19 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lucide-react@1.55.0: + resolution: {integrity: sha512-bSozQHiI3/8q7JvgoqVRGOYUi7fYT3siAZwmZsljjsOH2Husp/2ODdFf/leD+ZC/2yKfn7r8oh6yeoR3ITR5vA==} + peerDependencies: + '@types/react': '*' + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + macho-unsign@2.0.6: resolution: {integrity: sha512-YkIVGFnpVHJMMwfy4bHo79Vy05ddVk/PZGSCmmiCT4zepx+FMP/JAt9hOoXuc31s2bbcOtnzznOGca5fRhgZOg==} engines: {node: '>=18.12.0'} @@ -6804,6 +7230,12 @@ packages: resolution: {integrity: sha512-WmmLty1YWwJl9yZi77v2dVIV6X2kuYV8YYBI/G3LWGKdGHmHUvL1z7FW0iDvEvGAwNEoc5x1tOOOyDnf5jJw/w==} engines: {node: '>= 10'} + next-themes@0.4.6: + resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} + peerDependencies: + react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + nlcst-to-string@4.0.0: resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==} @@ -6862,6 +7294,27 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + nuqs@2.10.2: + resolution: {integrity: sha512-HaLMCr9H2wywEnTu3j+QW98EteDT3Kfmx3SJJ6JfcvCvkFi/g/fKd/oCXa5DF0x1s1l4zcFtzsq9bflj6RcM6A==} + peerDependencies: + '@remix-run/react': '>=2' + '@tanstack/react-router': ^1 + next: '>=14.2.0' + react: '>=18.2.0 || ^19.0.0-0' + react-router: ^5 || ^6 || ^7 || ^8 + react-router-dom: ^5 || ^6 || ^7 + peerDependenciesMeta: + '@remix-run/react': + optional: true + '@tanstack/react-router': + optional: true + next: + optional: true + react-router: + optional: true + react-router-dom: + optional: true + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -7020,6 +7473,9 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -7170,6 +7626,10 @@ packages: deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + pretty-ms@9.3.1: resolution: {integrity: sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==} engines: {node: '>=18'} @@ -7259,6 +7719,9 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-markdown@9.1.0: resolution: {integrity: sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==} peerDependencies: @@ -7275,12 +7738,32 @@ packages: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} - react-router-dom@7.18.1: - resolution: {integrity: sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==} - engines: {node: '>=20.0.0'} + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} peerDependencies: - react: '>=18' - react-dom: '>=18' + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-router-dom@7.18.1: + resolution: {integrity: sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' react-router@7.18.1: resolution: {integrity: sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==} @@ -7292,11 +7775,31 @@ packages: react-dom: optional: true + react-router@7.18.4: + resolution: {integrity: sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + react-scroll-to-bottom@4.2.0: resolution: {integrity: sha512-1WweuumQc5JLzeAR81ykRdK/cEv9NlCPEm4vSwOGN1qS2qlpGVTyMgdI8Y7ZmaqRmzYBGV5/xPuJQtekYzQFGg==} peerDependencies: react: '>= 16.8.6' + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + react@19.2.8: resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} engines: {node: '>=0.10.0'} @@ -7500,6 +8003,10 @@ packages: resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} engines: {node: '>=11.0.0'} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -7802,6 +8309,9 @@ packages: peerDependencies: react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tagged-tag@1.0.0: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} @@ -7809,6 +8319,9 @@ packages: tailwind-merge@3.3.1: resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==} + tailwind-merge@3.7.0: + resolution: {integrity: sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA==} + tailwindcss@4.3.3: resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} @@ -7887,6 +8400,13 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} + tldts-core@7.4.18: + resolution: {integrity: sha512-KI2PKvCmvQI/xD6ypPqkU0i7oxNXR/O58RLRaHR4svsgVUQ2JE/XC8BtMbG6PSehD27I8sZJRcC1ASnCy6lp3g==} + + tldts@7.4.18: + resolution: {integrity: sha512-somxA8xKAUzlAxl0TX/jMSYIbRNFIVGnI96pVGJPeMc5BsHmfVXn6mOJT6U++ELnl04eGfzpfcEuZe8WiASkXg==} + hasBin: true + tmp-promise@3.0.3: resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==} @@ -7909,9 +8429,17 @@ packages: resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} engines: {node: '>=6'} + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -8020,9 +8548,6 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} - undici-types@7.8.0: - resolution: {integrity: sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==} - undici@7.24.4: resolution: {integrity: sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==} engines: {node: '>=20.18.1'} @@ -8175,6 +8700,26 @@ packages: urlpattern-polyfill@10.1.0: resolution: {integrity: sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==} + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + use-sync-external-store@1.5.0: resolution: {integrity: sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==} peerDependencies: @@ -8354,6 +8899,10 @@ packages: jsdom: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + walk-back@5.1.1: resolution: {integrity: sha512-e/FRLDVdZQWFrAzU6Hdvpm7D7m2ina833gIKLptQykRK49mmCYHLHq7UqjPDbxbKLZkTkW1rFqbengdE3sLfdw==} engines: {node: '>=12.17'} @@ -8371,6 +8920,10 @@ packages: webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + webpack-sources@3.3.3: resolution: {integrity: sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==} engines: {node: '>=10.13.0'} @@ -8378,6 +8931,14 @@ packages: webpack-virtual-modules@0.5.0: resolution: {integrity: sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw==} + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -8510,6 +9071,13 @@ packages: utf-8-validate: optional: true + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xmlcreate@2.0.4: resolution: {integrity: sha512-nquOebG4sngPmGPICTS5EnxqhKbCmz5Ox5hsszI2T6U5qdrJizBc+0ilYSEjTSzU0yZcmvppztXe/5Al5fUwdg==} @@ -8632,6 +9200,8 @@ snapshots: optionalDependencies: express: 5.2.1 + '@acemir/cssom@0.9.32': {} + '@ai-sdk/anthropic@3.0.33(zod@4.4.3)': dependencies: '@ai-sdk/provider': 3.0.6 @@ -8792,6 +9362,26 @@ snapshots: transitivePeerDependencies: - supports-color + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) + '@csstools/css-color-parser': 4.2.6(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) + '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2) + '@csstools/css-tokenizer': 4.0.2 + + '@asamuzakjp/dom-selector@6.8.1': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.1.0 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + '@ast-grep/cli-darwin-arm64@0.43.0': optional: true @@ -8885,7 +9475,7 @@ snapshots: '@astrojs/internal-helpers@0.10.4': dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 js-yaml: 4.3.2 picomatch: 4.0.7 @@ -8896,7 +9486,7 @@ snapshots: '@astrojs/internal-helpers@0.11.0': dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 js-yaml: 4.3.2 picomatch: 4.0.7 @@ -9249,6 +9839,10 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@bramus/specificity@2.4.3': + dependencies: + css-tree: 3.2.1 + '@bruits/satteri-darwin-arm64@0.10.5': optional: true @@ -9386,7 +9980,7 @@ snapshots: optionalDependencies: workerd: 1.20260401.1 - '@cloudflare/vite-plugin@1.13.15(@types/node@24.13.5)(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3))(workerd@1.20260401.1)(wrangler@4.80.0(@cloudflare/workers-types@4.20260405.1)(@types/node@24.13.5))': + '@cloudflare/vite-plugin@1.13.15(@types/node@24.13.5)(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.33.0)(tsx@4.20.3)(yaml@2.8.3))(workerd@1.20260401.1)(wrangler@4.80.0(@cloudflare/workers-types@4.20260405.1)(@types/node@24.13.5))': dependencies: '@cloudflare/unenv-preset': 2.7.8(unenv@2.0.0-rc.21)(workerd@1.20260401.1) '@remix-run/node-fetch-server': 0.8.1 @@ -9395,7 +9989,7 @@ snapshots: picocolors: 1.1.1 tinyglobby: 0.2.17 unenv: 2.0.0-rc.21 - vite: 6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3) + vite: 6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.33.0)(tsx@4.20.3)(yaml@2.8.3) wrangler: 4.80.0(@cloudflare/workers-types@4.20260405.1)(@types/node@24.13.5) ws: 8.18.0 transitivePeerDependencies: @@ -9411,7 +10005,7 @@ snapshots: cjs-module-lexer: 1.4.3 esbuild: 0.27.3 miniflare: 4.20260401.0(@types/node@24.13.5) - vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.5)(@vitest/coverage-v8@4.1.11)(msw@2.10.2(@types/node@24.13.5)(typescript@5.9.3))(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3)) + vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.5)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@24.13.5)(typescript@5.9.3))(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.33.0)(tsx@4.20.3)(yaml@2.8.3)) wrangler: 4.80.0(@cloudflare/workers-types@4.20260405.1)(@types/node@24.13.5) zod: 3.25.76 transitivePeerDependencies: @@ -9473,6 +10067,30 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@csstools/color-helpers@6.1.2': {} + + '@csstools/css-calc@3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2) + '@csstools/css-tokenizer': 4.0.2 + + '@csstools/css-color-parser@4.2.6(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)': + dependencies: + '@csstools/color-helpers': 6.1.2 + '@csstools/css-calc': 3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) + '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2) + '@csstools/css-tokenizer': 4.0.2 + + '@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2)': + dependencies: + '@csstools/css-tokenizer': 4.0.2 + + '@csstools/css-syntax-patches-for-csstree@1.1.15(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.2': {} + '@ctrl/tinycolor@4.2.1': {} '@emnapi/core@1.11.1': @@ -9553,7 +10171,7 @@ snapshots: '@emotion/memoize': 0.9.0 '@emotion/unitless': 0.10.0 '@emotion/utils': 1.4.2 - csstype: 3.1.3 + csstype: 3.2.3 '@emotion/sheet@1.4.0': {} @@ -9872,6 +10490,8 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true + '@exodus/bytes@1.16.0': {} + '@expressive-code/core@0.44.2': dependencies: '@ctrl/tinycolor': 4.2.1 @@ -9879,8 +10499,8 @@ snapshots: hast-util-to-html: 9.0.5 hast-util-to-text: 4.0.2 hastscript: 9.0.1 - postcss: 8.5.26 - postcss-nested: 6.2.0(postcss@8.5.26) + postcss: 8.5.28 + postcss-nested: 6.2.0(postcss@8.5.28) unist-util-visit: 5.0.0 unist-util-visit-parents: 6.0.1 @@ -9901,13 +10521,13 @@ snapshots: '@fontsource/ibm-plex-mono@5.3.0': {} - '@hono/mcp@0.2.5(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(hono-rate-limiter@0.4.2(hono@4.13.8))(hono@4.13.8)(zod@4.4.3)': + '@hono/mcp@0.2.5(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.6.5))(hono-rate-limiter@0.4.2(hono@4.13.8))(hono@4.13.8)(zod@4.6.5)': dependencies: '@modelcontextprotocol/sdk': 1.30.0(@cfworker/json-schema@4.1.1)(zod@4.6.5) hono: 4.13.8 hono-rate-limiter: 0.4.2(hono@4.13.8) pkce-challenge: 5.0.1 - zod: 4.4.3 + zod: 4.6.5 '@hono/node-server@1.19.17(hono@4.13.8)': dependencies: @@ -10128,7 +10748,7 @@ snapshots: '@jridgewell/remapping@2.3.5': dependencies: '@jridgewell/gen-mapping': 0.3.12 - '@jridgewell/trace-mapping': 0.3.29 + '@jridgewell/trace-mapping': 0.3.31 '@jridgewell/resolve-uri@3.1.2': {} @@ -10136,11 +10756,6 @@ snapshots: '@jridgewell/sourcemap-codec@1.6.0': {} - '@jridgewell/trace-mapping@0.3.29': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -10244,7 +10859,7 @@ snapshots: dependencies: '@types/estree': 1.0.9 '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdx': 2.0.14 acorn: 8.15.0 collapse-white-space: 2.1.0 @@ -10326,7 +10941,7 @@ snapshots: pkce-challenge: 5.0.1 raw-body: 3.0.1 zod: 4.6.5 - zod-to-json-schema: 3.25.1(zod@4.4.3) + zod-to-json-schema: 3.25.1(zod@4.6.5) optionalDependencies: '@cfworker/json-schema': 4.1.1 transitivePeerDependencies: @@ -10671,6 +11286,8 @@ snapshots: '@radix-ui/primitive@1.1.7': {} + '@radix-ui/primitive@1.2.0': {} + '@radix-ui/react-accordion@1.2.20(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 @@ -10728,18 +11345,83 @@ snapshots: optionalDependencies: '@types/react': 19.1.8 + '@radix-ui/react-compose-refs@1.1.5(@types/react@19.3.0)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.3.0 + '@radix-ui/react-context@1.2.2(@types/react@19.1.8)(react@19.2.8)': dependencies: react: 19.2.8 optionalDependencies: '@types/react': 19.1.8 + '@radix-ui/react-context@1.3.0(@types/react@19.3.0)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-dialog@1.3.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.2.8) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-focus-guards': 1.2.0(@types/react@19.3.0)(react@19.2.8) + '@radix-ui/react-focus-scope': 1.3.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.2.8) + '@radix-ui/react-portal': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.2.8) + aria-hidden: 1.2.6 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-remove-scroll: 2.7.2(@types/react@19.3.0)(react@19.2.8) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + '@radix-ui/react-direction@1.1.4(@types/react@19.1.8)(react@19.2.8)': dependencies: react: 19.2.8 optionalDependencies: '@types/react': 19.1.8 + '@radix-ui/react-dismissable-layer@1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.2.8) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.2.8) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.3.0)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-focus-guards@1.2.0(@types/react@19.3.0)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-focus-scope@1.3.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.2.8) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + '@radix-ui/react-id@1.1.4(@types/react@19.1.8)(react@19.2.8)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.1.8)(react@19.2.8) @@ -10747,6 +11429,23 @@ snapshots: optionalDependencies: '@types/react': 19.1.8 + '@radix-ui/react-id@1.1.4(@types/react@19.3.0)(react@19.2.8)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-portal@1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + '@radix-ui/react-presence@1.1.10(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.1.8)(react@19.2.8) @@ -10756,6 +11455,15 @@ snapshots: '@types/react': 19.1.8 '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-presence@1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + '@radix-ui/react-primitive@2.1.10(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/react-slot': 1.3.3(@types/react@19.1.8)(react@19.2.8) @@ -10765,6 +11473,24 @@ snapshots: '@types/react': 19.1.8 '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-primitive@2.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-slot': 1.3.3(@types/react@19.3.0)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-primitive@2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + '@radix-ui/react-slot@1.2.3(@types/react@19.1.8)(react@19.2.8)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.2.8) @@ -10779,6 +11505,27 @@ snapshots: optionalDependencies: '@types/react': 19.1.8 + '@radix-ui/react-slot@1.3.3(@types/react@19.3.0)(react@19.2.8)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-slot@1.5.0(@types/react@19.3.0)(react@19.2.8)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-callback-ref@1.1.5(@types/react@19.3.0)(react@19.2.8)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.3.0 + '@radix-ui/react-use-controllable-state@1.2.6(@types/react@19.1.8)(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 @@ -10788,6 +11535,15 @@ snapshots: optionalDependencies: '@types/react': 19.1.8 + '@radix-ui/react-use-controllable-state@1.3.0(@types/react@19.3.0)(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.3.0)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.3.0 + '@radix-ui/react-use-effect-event@0.0.5(@types/react@19.1.8)(react@19.2.8)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.1.8)(react@19.2.8) @@ -10795,12 +11551,25 @@ snapshots: optionalDependencies: '@types/react': 19.1.8 + '@radix-ui/react-use-effect-event@0.0.5(@types/react@19.3.0)(react@19.2.8)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.3.0 + '@radix-ui/react-use-layout-effect@1.1.4(@types/react@19.1.8)(react@19.2.8)': dependencies: react: 19.2.8 optionalDependencies: '@types/react': 19.1.8 + '@radix-ui/react-use-layout-effect@1.1.4(@types/react@19.3.0)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.3.0 + '@remix-run/node-fetch-server@0.8.1': {} '@rolldown/binding-android-arm-eabi@1.2.9': @@ -10886,14 +11655,24 @@ snapshots: '@rolldown/binding-win32-x64-msvc@1.2.9': optional: true - '@rolldown/plugin-babel@0.2.3(@babel/core@7.28.0)(@babel/runtime@7.28.4)(rolldown@1.0.0-beta.23)(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3))': + '@rolldown/plugin-babel@0.2.3(@babel/core@7.28.0)(@babel/runtime@7.28.4)(rolldown@1.0.0-beta.23)(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.33.0)(tsx@4.20.3)(yaml@2.8.3))': dependencies: '@babel/core': 7.28.0 picomatch: 4.0.7 rolldown: 1.0.0-beta.23 optionalDependencies: '@babel/runtime': 7.28.4 - vite: 6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3) + vite: 6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.33.0)(tsx@4.20.3)(yaml@2.8.3) + + '@rolldown/plugin-babel@0.2.3(@babel/core@7.28.0)(@babel/runtime@7.28.4)(rolldown@1.2.9)(vite@8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))': + dependencies: + '@babel/core': 7.28.0 + picomatch: 4.0.7 + rolldown: 1.2.9 + optionalDependencies: + '@babel/runtime': 7.28.4 + vite: 8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0) + optional: true '@rolldown/pluginutils@1.0.0-beta.19': {} @@ -11321,14 +12100,14 @@ snapshots: '@sentry/conventions': 0.24.0 '@sentry/core': 11.1.0 - '@sentry/react@11.1.0(react-router@7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)': + '@sentry/react@11.1.0(react-router@7.18.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)': dependencies: '@sentry/browser': 11.1.0 '@sentry/conventions': 0.24.0 '@sentry/core': 11.1.0 react: 19.2.8 optionalDependencies: - react-router: 7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react-router: 7.18.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@sentry/replay-canvas@10.75.0': dependencies: @@ -11412,7 +12191,7 @@ snapshots: dependencies: '@shikijs/types': 3.23.0 '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-to-html: 9.0.5 '@shikijs/core@4.4.3': @@ -11511,9 +12290,9 @@ snapshots: '@spotlightjs/spotlight@4.11.8(@cfworker/json-schema@4.1.1)(@opentelemetry/core@2.11.0(@opentelemetry/api@1.9.1))(hono-rate-limiter@0.4.2(hono@4.13.8))': dependencies: - '@hono/mcp': 0.2.5(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(hono-rate-limiter@0.4.2(hono@4.13.8))(hono@4.13.8)(zod@4.4.3) + '@hono/mcp': 0.2.5(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.6.5))(hono-rate-limiter@0.4.2(hono@4.13.8))(hono@4.13.8)(zod@4.6.5) '@hono/node-server': 1.19.17(hono@4.13.8) - '@jridgewell/trace-mapping': 0.3.29 + '@jridgewell/trace-mapping': 0.3.31 '@modelcontextprotocol/sdk': 1.30.0(@cfworker/json-schema@4.1.1)(zod@4.6.5) '@sentry/core': 10.63.0(patch_hash=e663994979ff877a26ab6d4dea5968fbaee4ccdfdeb85623983535a572678940) '@sentry/node': 10.65.0(@opentelemetry/core@2.11.0(@opentelemetry/api@1.9.1)) @@ -11528,7 +12307,7 @@ snapshots: semver: 7.8.5 uuidv7: 1.2.1 yaml: 2.9.0 - zod: 4.4.3 + zod: 4.6.5 transitivePeerDependencies: - '@cfworker/json-schema' - '@opentelemetry/core' @@ -11615,18 +12394,48 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 4.3.3 - '@tailwindcss/vite@4.3.3(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3))': + '@tailwindcss/vite@4.3.3(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.33.0)(tsx@4.20.3)(yaml@2.8.3))': dependencies: '@tailwindcss/node': 4.3.3 '@tailwindcss/oxide': 4.3.3 tailwindcss: 4.3.3 - vite: 6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3) + vite: 6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.33.0)(tsx@4.20.3)(yaml@2.8.3) + + '@tailwindcss/vite@4.3.3(vite@8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))': + dependencies: + '@tailwindcss/node': 4.3.3 + '@tailwindcss/oxide': 4.3.3 + tailwindcss: 4.3.3 + vite: 8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0) + + '@testing-library/dom@10.4.2': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/runtime': 7.28.4 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@babel/runtime': 7.28.4 + '@testing-library/dom': 10.4.2 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) '@tybys/wasm-util@0.10.3': dependencies: tslib: 2.8.1 optional: true + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.28.0 @@ -11707,7 +12516,7 @@ snapshots: '@types/node-fetch@2.6.13': dependencies: - '@types/node': 24.0.10 + '@types/node': 24.13.5 form-data: 4.0.6 '@types/node@18.19.130': @@ -11722,10 +12531,6 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/node@24.0.10': - dependencies: - undici-types: 7.8.0 - '@types/node@24.13.5': dependencies: undici-types: 7.18.2 @@ -11736,7 +12541,7 @@ snapshots: '@types/pngjs@6.0.5': dependencies: - '@types/node': 24.0.10 + '@types/node': 24.13.5 '@types/qrcode-terminal@0.12.2': {} @@ -11744,9 +12549,13 @@ snapshots: dependencies: '@types/react': 19.1.8 + '@types/react-dom@19.3.0(@types/react@19.3.0)': + dependencies: + '@types/react': 19.3.0 + '@types/react-scroll-to-bottom@4.2.5': dependencies: - '@types/react': 19.1.8 + '@types/react': 19.3.0 '@types/react@19.1.8': dependencies: @@ -11758,7 +12567,7 @@ snapshots: '@types/sax@1.2.7': dependencies: - '@types/node': 24.0.10 + '@types/node': 24.13.5 '@types/semver@7.8.0': {} @@ -11770,6 +12579,11 @@ snapshots: '@types/unist@3.0.3': {} + '@uidotdev/usehooks@2.4.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + '@ungap/structured-clone@1.3.0': {} '@vercel/analytics@2.0.1(react@19.2.8)': @@ -11784,7 +12598,7 @@ snapshots: optionalDependencies: react: 19.2.8 - '@vitejs/plugin-react@4.6.0(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3))': + '@vitejs/plugin-react@4.6.0(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.33.0)(tsx@4.20.3)(yaml@2.8.3))': dependencies: '@babel/core': 7.28.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.0) @@ -11792,10 +12606,17 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.19 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3) + vite: 6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.33.0)(tsx@4.20.3)(yaml@2.8.3) transitivePeerDependencies: - supports-color + '@vitejs/plugin-react@6.1.2(@rolldown/plugin-babel@0.2.3(@babel/core@7.28.0)(@babel/runtime@7.28.4)(rolldown@1.2.9)(vite@8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)))(vite@8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0) + optionalDependencies: + '@rolldown/plugin-babel': 0.2.3(@babel/core@7.28.0)(@babel/runtime@7.28.4)(rolldown@1.2.9)(vite@8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + '@vitest/coverage-v8@4.1.11(vitest@4.1.11)': dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -11808,7 +12629,7 @@ snapshots: obug: 2.1.1 std-env: 4.0.0 tinyrainbow: 3.1.0 - vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.16.0)(@vitest/coverage-v8@4.1.11)(msw@2.10.2(@types/node@22.16.0)(typescript@5.8.3))(vite@8.3.0(@types/node@22.16.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0)) + vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.16.0)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@22.16.0)(typescript@5.8.3))(vite@8.3.0(@types/node@22.16.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0)) '@vitest/expect@4.1.11': dependencies: @@ -11864,14 +12685,14 @@ snapshots: msw: 2.10.2(@types/node@24.13.5)(typescript@5.8.3) vite: 8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0) - '@vitest/mocker@4.1.11(msw@2.10.2(@types/node@24.13.5)(typescript@5.9.3))(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3))': + '@vitest/mocker@4.1.11(msw@2.10.2(@types/node@24.13.5)(typescript@5.9.3))(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.33.0)(tsx@4.20.3)(yaml@2.8.3))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.10.2(@types/node@24.13.5)(typescript@5.9.3) - vite: 6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3) + vite: 6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.33.0)(tsx@4.20.3)(yaml@2.8.3) '@vitest/pretty-format@4.1.11': dependencies: @@ -11928,18 +12749,20 @@ snapshots: transitivePeerDependencies: - supports-color + agent-base@7.1.4: {} + agentkeepalive@4.6.0: dependencies: humanize-ms: 1.2.1 - agents@0.20.1(@ai-sdk/react@3.0.232(react@19.2.8)(zod@4.4.3))(@babel/core@7.28.0)(@babel/runtime@7.28.4)(@cloudflare/workers-types@4.20260405.1)(@modelcontextprotocol/client@2.0.0)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(@modelcontextprotocol/server@2.0.0)(ai@6.0.64(zod@4.4.3))(chat@4.40.0(ai@6.0.64(zod@4.4.3))(zod@4.4.3))(react@19.2.8)(rolldown@1.0.0-beta.23)(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3))(zod@4.4.3): + agents@0.20.1(@ai-sdk/react@3.0.232(react@19.2.8)(zod@4.4.3))(@babel/core@7.28.0)(@babel/runtime@7.28.4)(@cloudflare/workers-types@4.20260405.1)(@modelcontextprotocol/client@2.0.0)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(@modelcontextprotocol/server@2.0.0)(ai@6.0.64(zod@4.4.3))(chat@4.40.0(ai@6.0.64(zod@4.4.3))(zod@4.4.3))(react@19.2.8)(rolldown@1.0.0-beta.23)(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.33.0)(tsx@4.20.3)(yaml@2.8.3))(zod@4.4.3): dependencies: '@babel/plugin-proposal-decorators': 8.0.2(@babel/core@7.28.0) '@cfworker/json-schema': 4.1.1 '@modelcontextprotocol/client': 2.0.0 '@modelcontextprotocol/sdk': 1.30.0(@cfworker/json-schema@4.1.1)(zod@4.4.3) '@modelcontextprotocol/server': 2.0.0 - '@rolldown/plugin-babel': 0.2.3(@babel/core@7.28.0)(@babel/runtime@7.28.4)(rolldown@1.0.0-beta.23)(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3)) + '@rolldown/plugin-babel': 0.2.3(@babel/core@7.28.0)(@babel/runtime@7.28.4)(rolldown@1.0.0-beta.23)(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.33.0)(tsx@4.20.3)(yaml@2.8.3)) cron-schedule: 6.0.0 esbuild: 0.28.1 mimetext: 3.0.28 @@ -11954,7 +12777,7 @@ snapshots: '@ai-sdk/react': 3.0.232(react@19.2.8)(zod@4.4.3) ai: 6.0.64(zod@4.4.3) chat: 4.40.0(ai@6.0.64(zod@4.4.3))(zod@4.4.3) - vite: 6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3) + vite: 6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.33.0)(tsx@4.20.3)(yaml@2.8.3) transitivePeerDependencies: - '@babel/core' - '@babel/plugin-transform-runtime' @@ -12027,6 +12850,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} ansis@4.1.0: {} @@ -12048,6 +12873,14 @@ snapshots: argparse@2.0.1: {} + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + aria-query@5.3.2: {} array-back@6.2.2: {} @@ -12234,6 +13067,10 @@ snapshots: bindings: 1.5.0 prebuild-install: 7.1.3 + bidi-js@1.1.0: + dependencies: + require-from-string: 2.0.2 + binary-extensions@2.3.0: {} bindings@1.5.0: @@ -12467,6 +13304,18 @@ snapshots: clsx@2.1.1: {} + cmdk@1.1.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.2.8) + '@radix-ui/react-dialog': 1.3.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + code-excerpt@4.0.0: dependencies: convert-to-spaces: 2.0.1 @@ -12586,12 +13435,26 @@ snapshots: dependencies: css-tree: 2.2.1 + cssstyle@6.2.0: + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@csstools/css-syntax-patches-for-csstree': 1.1.15(css-tree@3.2.1) + css-tree: 3.2.1 + lru-cache: 11.5.2 + csstype@3.1.3: {} csstype@3.2.3: {} current-module-paths@1.1.2: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + debug@4.4.1: dependencies: ms: 2.1.3 @@ -12600,6 +13463,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + decode-named-character-reference@1.2.0: dependencies: character-entities: 2.0.2 @@ -12633,6 +13498,8 @@ snapshots: detect-libc@2.1.2: {} + detect-node-es@1.1.0: {} + devalue@5.9.2: {} devlop@1.1.0: @@ -12645,6 +13512,8 @@ snapshots: direction@2.0.1: {} + dom-accessibility-api@0.5.16: {} + dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -12715,6 +13584,8 @@ snapshots: entities@6.0.1: {} + entities@8.1.0: {} + environment@1.1.0: {} error-ex@1.3.2: @@ -13218,6 +14089,8 @@ snapshots: hasown: 2.0.2 math-intrinsics: 1.1.0 + get-nonce@1.0.1: {} + get-port@7.1.0: {} get-proto@1.0.1: @@ -13313,12 +14186,12 @@ snapshots: hast-util-embedded@3.0.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-is-element: 3.0.0 hast-util-format@1.1.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-embedded: 3.0.0 hast-util-minify-whitespace: 1.0.1 hast-util-phrasing: 3.0.1 @@ -13337,7 +14210,7 @@ snapshots: hast-util-from-parse5@8.0.3: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/unist': 3.0.3 devlop: 1.1.0 hastscript: 9.0.1 @@ -13348,19 +14221,19 @@ snapshots: hast-util-has-property@3.0.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-is-body-ok-link@3.0.1: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-is-element@3.0.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-minify-whitespace@1.0.1: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-embedded: 3.0.0 hast-util-is-element: 3.0.0 hast-util-whitespace: 3.0.0 @@ -13368,11 +14241,11 @@ snapshots: hast-util-parse-selector@4.0.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-phrasing@3.0.1: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-embedded: 3.0.0 hast-util-has-property: 3.0.0 hast-util-is-body-ok-link: 3.0.1 @@ -13380,7 +14253,7 @@ snapshots: hast-util-raw@9.1.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/unist': 3.0.3 '@ungap/structured-clone': 1.3.0 hast-util-from-parse5: 8.0.3 @@ -13416,7 +14289,7 @@ snapshots: dependencies: '@types/estree': 1.0.9 '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 comma-separated-tokens: 2.0.3 devlop: 1.1.0 estree-util-attach-comments: 3.0.0 @@ -13435,7 +14308,7 @@ snapshots: hast-util-to-html@9.0.5: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/unist': 3.0.3 ccount: 2.0.1 comma-separated-tokens: 2.0.3 @@ -13450,7 +14323,7 @@ snapshots: hast-util-to-jsx-runtime@2.3.6: dependencies: '@types/estree': 1.0.8 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/unist': 3.0.3 comma-separated-tokens: 2.0.3 devlop: 1.1.0 @@ -13469,7 +14342,7 @@ snapshots: hast-util-to-parse5@8.0.1: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 comma-separated-tokens: 2.0.3 devlop: 1.1.0 property-information: 7.1.0 @@ -13483,14 +14356,14 @@ snapshots: hast-util-to-text@4.0.2: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/unist': 3.0.3 hast-util-is-element: 3.0.0 unist-util-find-after: 5.0.0 hast-util-whitespace@3.0.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hastscript@9.0.1: dependencies: @@ -13512,6 +14385,12 @@ snapshots: hookable@5.5.3: {} + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.16.0 + transitivePeerDependencies: + - '@noble/hashes' + html-escaper@2.0.2: {} html-escaper@3.0.3: {} @@ -13532,6 +14411,13 @@ snapshots: statuses: 2.0.1 toidentifier: 1.0.1 + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 @@ -13539,6 +14425,13 @@ snapshots: transitivePeerDependencies: - supports-color + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + human-signals@5.0.0: {} human-signals@8.0.1: {} @@ -13686,6 +14579,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + is-promise@4.0.0: {} is-stream@3.0.0: {} @@ -13776,6 +14671,33 @@ snapshots: strip-json-comments: 3.1.1 underscore: 1.13.7 + jsdom@28.1.0: + dependencies: + '@acemir/cssom': 0.9.32 + '@asamuzakjp/dom-selector': 6.8.1 + '@bramus/specificity': 2.4.3 + '@exodus/bytes': 1.16.0 + cssstyle: 6.2.0 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + undici: 7.24.4 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + - supports-color + jsesc@3.1.0: {} json-parse-even-better-errors@2.3.1: {} @@ -13990,6 +14912,14 @@ snapshots: dependencies: react: 19.2.8 + lucide-react@1.55.0(@types/react@19.3.0)(react@19.2.8): + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.3.0 + + lz-string@1.5.0: {} + macho-unsign@2.0.6: {} magic-string@0.30.21: @@ -14148,7 +15078,7 @@ snapshots: mdast-util-mdx-expression@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 devlop: 1.1.0 mdast-util-from-markdown: 2.0.2 @@ -14159,7 +15089,7 @@ snapshots: mdast-util-mdx-jsx@3.2.0: dependencies: '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 '@types/unist': 3.0.3 ccount: 2.0.1 @@ -14186,7 +15116,7 @@ snapshots: mdast-util-mdxjs-esm@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 devlop: 1.1.0 mdast-util-from-markdown: 2.0.2 @@ -14201,7 +15131,7 @@ snapshots: mdast-util-to-hast@13.2.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 '@ungap/structured-clone': 1.3.0 devlop: 1.1.0 @@ -14762,6 +15692,11 @@ snapshots: neotraverse@1.0.1: {} + next-themes@0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + nlcst-to-string@4.0.0: dependencies: '@types/nlcst': 2.0.3 @@ -14809,6 +15744,14 @@ snapshots: dependencies: boolbase: 1.0.0 + nuqs@2.10.2(react-router-dom@7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-router@7.18.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8): + dependencies: + '@standard-schema/spec': 1.1.0 + react: 19.2.8 + optionalDependencies: + react-router: 7.18.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react-router-dom: 7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -15021,6 +15964,10 @@ snapshots: dependencies: entities: 6.0.1 + parse5@8.0.1: + dependencies: + entities: 8.1.0 + parseurl@1.3.3: {} partyserver@0.5.8(@cloudflare/workers-types@4.20260405.1): @@ -15094,9 +16041,9 @@ snapshots: portable-executable-signature@2.0.6: {} - postcss-nested@6.2.0(postcss@8.5.26): + postcss-nested@6.2.0(postcss@8.5.28): dependencies: - postcss: 8.5.26 + postcss: 8.5.28 postcss-selector-parser: 6.1.4 postcss-selector-parser@6.0.10: @@ -15144,6 +16091,12 @@ snapshots: tar-fs: 2.1.5 tunnel-agent: 0.6.0 + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + pretty-ms@9.3.1: dependencies: parse-ms: 4.0.0 @@ -15229,6 +16182,8 @@ snapshots: react-is@16.13.1: {} + react-is@17.0.2: {} + react-markdown@9.1.0(@types/react@19.1.8)(react@19.2.8): dependencies: '@types/hast': 3.0.4 @@ -15254,6 +16209,25 @@ snapshots: react-refresh@0.17.0: {} + react-remove-scroll-bar@2.3.8(@types/react@19.3.0)(react@19.2.8): + dependencies: + react: 19.2.8 + react-style-singleton: 2.2.3(@types/react@19.3.0)(react@19.2.8) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.3.0 + + react-remove-scroll@2.7.2(@types/react@19.3.0)(react@19.2.8): + dependencies: + react: 19.2.8 + react-remove-scroll-bar: 2.3.8(@types/react@19.3.0)(react@19.2.8) + react-style-singleton: 2.2.3(@types/react@19.3.0)(react@19.2.8) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.3.0)(react@19.2.8) + use-sidecar: 1.1.3(@types/react@19.3.0)(react@19.2.8) + optionalDependencies: + '@types/react': 19.3.0 + react-router-dom@7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: react: 19.2.8 @@ -15268,6 +16242,14 @@ snapshots: optionalDependencies: react-dom: 19.2.8(react@19.2.8) + react-router@7.18.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + cookie: 1.0.2 + react: 19.2.8 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.8(react@19.2.8) + react-scroll-to-bottom@4.2.0(@babel/core@7.28.0)(react@19.2.8): dependencies: '@babel/runtime-corejs3': 7.28.0 @@ -15282,6 +16264,14 @@ snapshots: - '@babel/core' - supports-color + react-style-singleton@2.2.3(@types/react@19.3.0)(react@19.2.8): + dependencies: + get-nonce: 1.0.1 + react: 19.2.8 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.3.0 + react@19.2.8: {} readable-stream@3.6.2: @@ -15348,27 +16338,27 @@ snapshots: rehype-parse@9.0.1: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-from-html: 2.0.3 unified: 11.0.5 rehype-raw@7.0.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-raw: 9.1.0 vfile: 6.0.3 rehype-recma@1.0.0: dependencies: '@types/estree': 1.0.9 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-to-estree: 3.1.3 transitivePeerDependencies: - supports-color rehype-stringify@10.0.1: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-to-html: 9.0.5 unified: 11.0.5 @@ -15417,7 +16407,7 @@ snapshots: remark-rehype@11.1.2: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 mdast-util-to-hast: 13.2.0 unified: 11.0.5 @@ -15667,6 +16657,10 @@ snapshots: sax@1.6.1: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} section-matter@1.0.0: @@ -15858,7 +16852,7 @@ snapshots: solid-js@1.9.9: dependencies: - csstype: 3.1.3 + csstype: 3.2.3 seroval: 1.5.6 seroval-plugins: 1.3.3(seroval@1.5.6) @@ -16001,10 +16995,14 @@ snapshots: react: 19.2.8 use-sync-external-store: 1.5.0(react@19.2.8) + symbol-tree@3.2.4: {} + tagged-tag@1.0.0: {} tailwind-merge@3.3.1: {} + tailwind-merge@3.7.0: {} + tailwindcss@4.3.3: {} tapable@2.3.3: {} @@ -16088,6 +17086,12 @@ snapshots: tinyrainbow@3.1.0: {} + tldts-core@7.4.18: {} + + tldts@7.4.18: + dependencies: + tldts-core: 7.4.18 + tmp-promise@3.0.3: dependencies: tmp: 0.2.3 @@ -16109,8 +17113,16 @@ snapshots: universalify: 0.2.0 url-parse: 1.5.10 + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.18 + tr46@0.0.3: {} + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + trim-lines@3.0.1: {} trough@2.2.0: {} @@ -16165,8 +17177,7 @@ snapshots: - supports-color - vue-tsc - tslib@2.8.1: - optional: true + tslib@2.8.1: {} tsx@4.20.3: dependencies: @@ -16230,8 +17241,6 @@ snapshots: undici-types@7.18.2: {} - undici-types@7.8.0: {} - undici@7.24.4: {} unenv@2.0.0-rc.21: @@ -16365,6 +17374,21 @@ snapshots: urlpattern-polyfill@10.1.0: {} + use-callback-ref@1.3.3(@types/react@19.3.0)(react@19.2.8): + dependencies: + react: 19.2.8 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.3.0 + + use-sidecar@1.1.3(@types/react@19.3.0)(react@19.2.8): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.8 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.3.0 + use-sync-external-store@1.5.0(react@19.2.8): dependencies: react: 19.2.8 @@ -16402,7 +17426,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.2 - vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3): + vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.33.0)(tsx@4.20.3)(yaml@2.8.3): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.5) @@ -16414,7 +17438,7 @@ snapshots: '@types/node': 24.13.5 fsevents: 2.3.3 jiti: 2.4.2 - lightningcss: 1.32.0 + lightningcss: 1.33.0 tsx: 4.20.3 yaml: 2.8.3 @@ -16500,9 +17524,9 @@ snapshots: vitest-evals@0.4.0(tinyrainbow@3.1.0)(vitest@4.1.11): dependencies: tinyrainbow: 3.1.0 - vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.16.0)(@vitest/coverage-v8@4.1.11)(msw@2.10.2(@types/node@22.16.0)(typescript@5.8.3))(vite@8.3.0(@types/node@22.16.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0)) + vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.16.0)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@22.16.0)(typescript@5.8.3))(vite@8.3.0(@types/node@22.16.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0)) - vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.16.0)(@vitest/coverage-v8@4.1.11)(msw@2.10.2(@types/node@22.16.0)(typescript@5.8.3))(vite@8.3.0(@types/node@22.16.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0)): + vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.16.0)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@22.16.0)(typescript@5.8.3))(vite@8.3.0(@types/node@22.16.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 '@vitest/mocker': 4.1.11(msw@2.10.2(@types/node@22.16.0)(typescript@5.8.3))(vite@8.3.0(@types/node@22.16.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0)) @@ -16528,10 +17552,11 @@ snapshots: '@opentelemetry/api': 1.9.1 '@types/node': 22.16.0 '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) + jsdom: 28.1.0 transitivePeerDependencies: - msw - vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.16.0)(@vitest/coverage-v8@4.1.11)(msw@2.10.2(@types/node@22.16.0)(typescript@5.9.3))(vite@8.3.0(@types/node@22.16.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)): + vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.16.0)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@22.16.0)(typescript@5.9.3))(vite@8.3.0(@types/node@22.16.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 '@vitest/mocker': 4.1.11(msw@2.10.2(@types/node@22.16.0)(typescript@5.9.3))(vite@8.3.0(@types/node@22.16.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) @@ -16557,10 +17582,11 @@ snapshots: '@opentelemetry/api': 1.9.1 '@types/node': 22.16.0 '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) + jsdom: 28.1.0 transitivePeerDependencies: - msw - vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.20.3)(@vitest/coverage-v8@4.1.11)(msw@2.10.2(@types/node@22.20.3)(typescript@5.9.3))(vite@8.3.0(@types/node@22.20.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)): + vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.20.3)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@22.20.3)(typescript@5.9.3))(vite@8.3.0(@types/node@22.20.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 '@vitest/mocker': 4.1.11(msw@2.10.2(@types/node@22.20.3)(typescript@5.9.3))(vite@8.3.0(@types/node@22.20.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) @@ -16586,10 +17612,11 @@ snapshots: '@opentelemetry/api': 1.9.1 '@types/node': 22.20.3 '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) + jsdom: 28.1.0 transitivePeerDependencies: - msw - vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.5)(@vitest/coverage-v8@4.1.11)(msw@2.10.2(@types/node@24.13.5)(typescript@5.8.3))(vite@8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0)): + vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.5)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@24.13.5)(typescript@5.8.3))(vite@8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 '@vitest/mocker': 4.1.11(msw@2.10.2(@types/node@24.13.5)(typescript@5.8.3))(vite@8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0)) @@ -16615,10 +17642,11 @@ snapshots: '@opentelemetry/api': 1.9.1 '@types/node': 24.13.5 '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) + jsdom: 28.1.0 transitivePeerDependencies: - msw - vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.5)(@vitest/coverage-v8@4.1.11)(msw@2.10.2(@types/node@24.13.5)(typescript@5.8.3))(vite@8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)): + vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.5)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@24.13.5)(typescript@5.8.3))(vite@8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 '@vitest/mocker': 4.1.11(msw@2.10.2(@types/node@24.13.5)(typescript@5.8.3))(vite@8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) @@ -16644,13 +17672,14 @@ snapshots: '@opentelemetry/api': 1.9.1 '@types/node': 24.13.5 '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) + jsdom: 28.1.0 transitivePeerDependencies: - msw - vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.5)(@vitest/coverage-v8@4.1.11)(msw@2.10.2(@types/node@24.13.5)(typescript@5.9.3))(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3)): + vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.5)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@24.13.5)(typescript@5.9.3))(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.33.0)(tsx@4.20.3)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(msw@2.10.2(@types/node@24.13.5)(typescript@5.9.3))(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3)) + '@vitest/mocker': 4.1.11(msw@2.10.2(@types/node@24.13.5)(typescript@5.9.3))(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.33.0)(tsx@4.20.3)(yaml@2.8.3)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -16667,15 +17696,20 @@ snapshots: tinyexec: 1.3.1 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3) + vite: 6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.33.0)(tsx@4.20.3)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/node': 24.13.5 '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) + jsdom: 28.1.0 transitivePeerDependencies: - msw + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + walk-back@5.1.1: {} web-namespaces@2.0.1: {} @@ -16686,10 +17720,22 @@ snapshots: webidl-conversions@3.0.1: {} + webidl-conversions@8.0.1: {} + webpack-sources@3.3.3: {} webpack-virtual-modules@0.5.0: {} + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.16.0 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 @@ -16831,6 +17877,10 @@ snapshots: ws@8.21.3: {} + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + xmlcreate@2.0.4: {} xxhash-wasm@1.1.0: {} @@ -16911,7 +17961,7 @@ snapshots: zod-from-json-schema@0.5.6: dependencies: - zod: 4.4.3 + zod: 4.6.5 zod-to-json-schema@3.25.1(zod@4.4.3): dependencies: